From 3614ee6fd6716df457ced7f4f64b687c6044d3f0 Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 19:01:11 +0800 Subject: [PATCH 1/6] refactor(cli): extract desktop and watch orchestration --- src/codex-desktop-launch.ts | 144 ++- src/commands/desktop.ts | 384 +++++++ src/desktop-managed-state.ts | 182 +++ src/main.ts | 1595 ++------------------------- src/switching.ts | 482 ++++++++ src/watch-detached.ts | 33 + src/watch-output.ts | 117 ++ src/watch-session.ts | 516 +++++++++ tests/desktop-managed-state.test.ts | 55 + tests/watch-detached.test.ts | 94 ++ tests/watch-output.test.ts | 60 + 11 files changed, 2057 insertions(+), 1605 deletions(-) create mode 100644 src/commands/desktop.ts create mode 100644 src/desktop-managed-state.ts create mode 100644 src/switching.ts create mode 100644 src/watch-detached.ts create mode 100644 src/watch-output.ts create mode 100644 src/watch-session.ts create mode 100644 tests/desktop-managed-state.test.ts create mode 100644 tests/watch-detached.test.ts create mode 100644 tests/watch-output.test.ts diff --git a/src/codex-desktop-launch.ts b/src/codex-desktop-launch.ts index 25140e5..95deb27 100644 --- a/src/codex-desktop-launch.ts +++ b/src/codex-desktop-launch.ts @@ -1722,7 +1722,10 @@ export function createCodexDesktopLauncher(options: { return isManagedDesktopProcess(runningApps, state); } - async function readDesktopRuntimeAccount(): Promise { + async function readDesktopRuntimeSnapshot( + expression: string, + normalize: (rawResult: unknown) => TSnapshot | null, + ): Promise { const state = await readManagedState(); if (!state) { return null; @@ -1737,131 +1740,118 @@ export function createCodexDesktopLauncher(options: { const rawResult = await evaluateDevtoolsExpressionWithResult( createWebSocketImpl, webSocketDebuggerUrl, - buildManagedCurrentAccountExpression(), + expression, DEVTOOLS_REQUEST_TIMEOUT_MS, ); - return normalizeRuntimeAccountSnapshot(rawResult); + return normalize(rawResult); } - async function readDesktopRuntimeQuota(): Promise { - const state = await readManagedState(); - if (!state) { - return null; - } - - const runningApps = await listRunningApps(); - if (!isManagedDesktopProcess(runningApps, state)) { - return null; - } + async function readDesktopRuntimeAccount(): Promise { + return await readDesktopRuntimeSnapshot( + buildManagedCurrentAccountExpression(), + normalizeRuntimeAccountSnapshot, + ); + } - const webSocketDebuggerUrl = await resolveLocalDevtoolsTarget(fetchImpl, state); - const rawResult = await evaluateDevtoolsExpressionWithResult( - createWebSocketImpl, - webSocketDebuggerUrl, + async function readDesktopRuntimeQuota(): Promise { + return await readDesktopRuntimeSnapshot( buildManagedCurrentQuotaExpression(), - DEVTOOLS_REQUEST_TIMEOUT_MS, + normalizeRuntimeQuotaSnapshot, ); - - return normalizeRuntimeQuotaSnapshot(rawResult); } - async function readDirectRuntimeAccount(): Promise { + async function readDirectRuntimeSnapshot(options: { + method: string; + params: Record; + normalize: (rawResult: unknown) => TSnapshot | null; + }): Promise { const directClient = await createDirectClientImpl(); try { - const rawResult = await directClient.request("account/read", { - refreshToken: false, - }); - return normalizeRuntimeAccountSnapshot(rawResult); + const rawResult = await directClient.request(options.method, options.params); + return options.normalize(rawResult); } finally { await directClient.close(); } } - async function readDirectRuntimeQuota(): Promise { - const directClient = await createDirectClientImpl(); + async function readDirectRuntimeAccount(): Promise { + return await readDirectRuntimeSnapshot({ + method: "account/read", + params: { + refreshToken: false, + }, + normalize: normalizeRuntimeAccountSnapshot, + }); + } - try { - const rawResult = await directClient.request("account/rateLimits/read", {}); - return normalizeRuntimeQuotaSnapshot(rawResult); - } finally { - await directClient.close(); - } + async function readDirectRuntimeQuota(): Promise { + return await readDirectRuntimeSnapshot({ + method: "account/rateLimits/read", + params: {}, + normalize: normalizeRuntimeQuotaSnapshot, + }); } - async function readCurrentRuntimeAccountResult(): Promise | null> { + async function readCurrentRuntimeSnapshotResult(options: { + readDesktop: () => Promise; + readDirect: () => Promise; + desktopFailureMessage: string; + directFailureMessage: string; + }): Promise | null> { let desktopError: Error | null = null; try { - const desktopAccount = await readDesktopRuntimeAccount(); - if (desktopAccount) { + const desktopSnapshot = await options.readDesktop(); + if (desktopSnapshot) { return { - snapshot: desktopAccount, + snapshot: desktopSnapshot, source: "desktop", }; } } catch (error) { - desktopError = toErrorMessage(error, "Failed to read the current Desktop runtime account."); + desktopError = toErrorMessage(error, options.desktopFailureMessage); } try { - const directAccount = await readDirectRuntimeAccount(); - if (!directAccount) { + const directSnapshot = await options.readDirect(); + if (!directSnapshot) { return null; } return { - snapshot: directAccount, + snapshot: directSnapshot, source: "direct", }; } catch (error) { - const directError = toErrorMessage(error, "Failed to read the direct runtime account."); + const directError = toErrorMessage(error, options.directFailureMessage); if (!desktopError) { throw directError; } throw new Error( - `${desktopError.message} Fallback direct runtime account read failed: ${directError.message}`, + `${desktopError.message} Fallback direct runtime read failed: ${directError.message}`, ); } } - async function readCurrentRuntimeQuotaResult(): Promise | null> { - let desktopError: Error | null = null; - - try { - const desktopQuota = await readDesktopRuntimeQuota(); - if (desktopQuota) { - return { - snapshot: desktopQuota, - source: "desktop", - }; - } - } catch (error) { - desktopError = toErrorMessage(error, "Failed to read the current Desktop runtime quota."); - } - - try { - const directQuota = await readDirectRuntimeQuota(); - if (!directQuota) { - return null; - } - - return { - snapshot: directQuota, - source: "direct", - }; - } catch (error) { - const directError = toErrorMessage(error, "Failed to read the direct runtime quota."); - if (!desktopError) { - throw directError; - } + async function readCurrentRuntimeAccountResult(): Promise | null> { + return await readCurrentRuntimeSnapshotResult({ + readDesktop: readDesktopRuntimeAccount, + readDirect: readDirectRuntimeAccount, + desktopFailureMessage: "Failed to read the current Desktop runtime account.", + directFailureMessage: "Failed to read the direct runtime account.", + }); + } - throw new Error( - `${desktopError.message} Fallback direct runtime quota read failed: ${directError.message}`, - ); - } + async function readCurrentRuntimeQuotaResult(): Promise | null> { + return await readCurrentRuntimeSnapshotResult({ + readDesktop: readDesktopRuntimeQuota, + readDirect: readDirectRuntimeQuota, + desktopFailureMessage: "Failed to read the current Desktop runtime quota.", + directFailureMessage: "Failed to read the direct runtime quota.", + }); } async function readCurrentRuntimeAccount(): Promise { diff --git a/src/commands/desktop.ts b/src/commands/desktop.ts new file mode 100644 index 0000000..5c37569 --- /dev/null +++ b/src/commands/desktop.ts @@ -0,0 +1,384 @@ +import type { AccountStore } from "../account-store.js"; +import { maskAccountId } from "../auth-snapshot.js"; +import type { ParsedArgs } from "../cli/args.js"; +import type { CodexDesktopLauncher } from "../codex-desktop-launch.js"; +import { writeJson } from "../cli/output.js"; +import { + confirmDesktopRelaunch, + isOnlyManagedDesktopInstanceRunning, + resolveManagedDesktopState, + restoreLaunchBackup, +} from "../desktop-managed-state.js"; +import { getPlatform } from "../platform.js"; +import type { WatchProcessManager } from "../watch-process.js"; +import { ensureDetachedWatch } from "../watch-detached.js"; +import { + describeBusySwitchLock, + resolveManagedAccountByName, + selectAutoSwitchAccount, + stripManagedDesktopWarning, + tryAcquireSwitchLock, +} from "../switching.js"; +import { runCliWatchSession, runManagedDesktopWatchSession } from "../watch-session.js"; + +const INTERNAL_LAUNCH_REFUSAL_MESSAGE = + 'Refusing to run "codexm launch" from inside Codex Desktop because quitting the app would terminate this session. Run this command from an external terminal instead.'; + +interface CliStreams { + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; +} + +export async function handleLaunchCommand(options: { + parsed: ParsedArgs; + json: boolean; + debug: boolean; + store: AccountStore; + desktopLauncher: CodexDesktopLauncher; + watchProcessManager: WatchProcessManager; + streams: CliStreams; + debugLog: (message: string) => void; +}): Promise { + const { + parsed, + json, + debug, + store, + desktopLauncher, + watchProcessManager, + streams, + debugLog, + } = options; + + const name = parsed.positionals[0] ?? null; + const auto = parsed.flags.has("--auto"); + const watch = parsed.flags.has("--watch"); + const noAutoSwitch = parsed.flags.has("--no-auto-switch"); + + if ( + parsed.positionals.length > 1 || + (auto && name) || + (noAutoSwitch && !watch) + ) { + throw new Error("Usage: codexm launch [name] [--auto] [--watch] [--no-auto-switch] [--json]"); + } + + if (await desktopLauncher.isRunningInsideDesktopShell()) { + throw new Error(INTERNAL_LAUNCH_REFUSAL_MESSAGE); + } + + const launchPlatform = await getPlatform(); + if (launchPlatform !== "darwin") { + throw new Error( + launchPlatform === "wsl" + ? "codexm launch is not supported on WSL. Use \"codexm run [-- ...args]\" to start codex with auto-restart on auth changes." + : "codexm launch is not supported on Linux. Use \"codexm run [-- ...args]\" to start codex with auto-restart on auth changes.", + ); + } + + const warnings: string[] = []; + const watchAutoSwitch = !noAutoSwitch; + const appPath = await desktopLauncher.findInstalledApp(); + if (!appPath) { + throw new Error("Codex Desktop not found at /Applications/Codex.app."); + } + debugLog(`launch: requested_account=${name ?? "current"}`); + debugLog(`launch: using app path ${appPath}`); + + const runningApps = await desktopLauncher.listRunningApps(); + debugLog(`launch: running_desktop_instances=${runningApps.length}`); + if (runningApps.length > 0) { + const managedDesktopState = await desktopLauncher.readManagedState(); + const canRelaunchGracefully = isOnlyManagedDesktopInstanceRunning( + runningApps, + managedDesktopState, + launchPlatform, + ); + const confirmed = await confirmDesktopRelaunch( + streams, + canRelaunchGracefully + ? "Codex Desktop is already running. Close it and relaunch with the selected auth? [y/N] " + : "Codex Desktop is already running outside codexm. Force-kill it and relaunch with the selected auth? [y/N] ", + ); + if (!confirmed) { + if (json) { + writeJson(streams.stdout, { + ok: false, + action: "launch", + cancelled: true, + }); + } else { + streams.stdout.write("Aborted.\n"); + } + return 1; + } + + await desktopLauncher.quitRunningApps({ force: !canRelaunchGracefully }); + } + + let switchedAccount: Awaited>["account"] | null = null; + let switchBackupPath: string | null = null; + const requestedTargetName = name; + + if (auto || requestedTargetName) { + const launchCommand = auto ? "launch --auto" : `launch ${requestedTargetName}`; + const lock = await tryAcquireSwitchLock(store, launchCommand); + if (!lock.acquired) { + throw new Error(describeBusySwitchLock(lock.lockPath, lock.owner)); + } + + try { + const targetName = auto + ? (await selectAutoSwitchAccount(store)).selected.name + : requestedTargetName; + if (auto) { + debugLog(`launch: auto-selected account=${targetName ?? "current"}`); + } + const currentStatus = await store.getCurrentStatus(); + if (targetName && !currentStatus.matched_accounts.includes(targetName)) { + const switchResult = await store.switchAccount(targetName); + warnings.push(...stripManagedDesktopWarning(switchResult.warnings)); + switchedAccount = switchResult.account; + switchBackupPath = switchResult.backup_path; + debugLog(`launch: pre-switched account=${switchResult.account.name}`); + } else if (targetName) { + switchedAccount = await resolveManagedAccountByName(store, targetName); + } + + try { + await desktopLauncher.launch(appPath); + const managedState = await resolveManagedDesktopState( + desktopLauncher, + appPath, + runningApps, + launchPlatform, + ); + if (!managedState) { + await desktopLauncher.clearManagedState().catch(() => undefined); + throw new Error( + "Failed to confirm the newly launched Codex Desktop process for managed-session tracking.", + ); + } + await desktopLauncher.writeManagedState(managedState); + debugLog( + `launch: recorded managed desktop pid=${managedState.pid} port=${managedState.remote_debugging_port}`, + ); + } catch (error) { + if (switchedAccount) { + await restoreLaunchBackup(store, switchBackupPath).catch(() => undefined); + debugLog( + `launch: restored previous auth after failure for account=${switchedAccount.name}`, + ); + } + throw error; + } + } finally { + await lock.release(); + } + } else { + await desktopLauncher.launch(appPath); + const managedState = await resolveManagedDesktopState( + desktopLauncher, + appPath, + runningApps, + launchPlatform, + ); + if (!managedState) { + await desktopLauncher.clearManagedState().catch(() => undefined); + throw new Error( + "Failed to confirm the newly launched Codex Desktop process for managed-session tracking.", + ); + } + await desktopLauncher.writeManagedState(managedState); + debugLog( + `launch: recorded managed desktop pid=${managedState.pid} port=${managedState.remote_debugging_port}`, + ); + } + + let detachedWatchResult: + | Awaited> + | null = null; + if (watch) { + detachedWatchResult = await ensureDetachedWatch(watchProcessManager, { + autoSwitch: watchAutoSwitch, + debug, + }); + } + + if (json) { + writeJson(streams.stdout, { + ok: true, + action: "launch", + account: switchedAccount + ? { + name: switchedAccount.name, + account_id: switchedAccount.account_id, + user_id: switchedAccount.user_id ?? null, + identity: switchedAccount.identity, + auth_mode: switchedAccount.auth_mode, + } + : null, + launched_with_current_auth: switchedAccount === null, + app_path: appPath, + relaunched: runningApps.length > 0, + watch: + detachedWatchResult === null + ? null + : { + action: detachedWatchResult.action, + pid: detachedWatchResult.state.pid, + started_at: detachedWatchResult.state.started_at, + log_path: detachedWatchResult.state.log_path, + auto_switch: detachedWatchResult.state.auto_switch, + }, + warnings, + }); + } else { + if (switchedAccount) { + streams.stdout.write( + `Switched to "${switchedAccount.name}" (${maskAccountId(switchedAccount.identity)}).\n`, + ); + } + if (runningApps.length > 0) { + streams.stdout.write("Closed existing Codex Desktop instance and launched a new one.\n"); + } + streams.stdout.write( + switchedAccount + ? `Launched Codex Desktop with "${switchedAccount.name}" (${maskAccountId(switchedAccount.identity)}).\n` + : "Launched Codex Desktop with current auth.\n", + ); + if (detachedWatchResult) { + if (detachedWatchResult.action === "reused") { + streams.stdout.write( + `Background watch already running (pid ${detachedWatchResult.state.pid}).\n`, + ); + } else { + streams.stdout.write( + `Started background watch (pid ${detachedWatchResult.state.pid}).\n`, + ); + } + streams.stdout.write(`Log: ${detachedWatchResult.state.log_path}\n`); + } + for (const warning of warnings) { + streams.stdout.write(`Warning: ${warning}\n`); + } + } + + return 0; +} + +export async function handleWatchCommand(options: { + parsed: ParsedArgs; + store: AccountStore; + desktopLauncher: CodexDesktopLauncher; + watchProcessManager: WatchProcessManager; + streams: CliStreams; + interruptSignal?: AbortSignal; + debug: boolean; + debugLog: (message: string) => void; + managedDesktopWaitStatusDelayMs: number; + managedDesktopWaitStatusIntervalMs: number; + watchQuotaMinReadIntervalMs: number; + watchQuotaIdleReadIntervalMs: number; +}): Promise { + const { + parsed, + store, + desktopLauncher, + watchProcessManager, + streams, + interruptSignal, + debug, + debugLog, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + watchQuotaMinReadIntervalMs, + watchQuotaIdleReadIntervalMs, + } = options; + + if (parsed.positionals.length > 0) { + throw new Error("Usage: codexm watch [--no-auto-switch] [--detach] [--status] [--stop]"); + } + + const autoSwitch = !parsed.flags.has("--no-auto-switch"); + const detach = parsed.flags.has("--detach"); + const status = parsed.flags.has("--status"); + const stop = parsed.flags.has("--stop"); + const modeCount = [detach, status, stop].filter(Boolean).length; + + if (modeCount > 1 || ((status || stop) && parsed.flags.has("--no-auto-switch"))) { + throw new Error("Usage: codexm watch [--no-auto-switch] [--detach] [--status] [--stop]"); + } + + if (status) { + const watchStatus = await watchProcessManager.getStatus(); + if (!watchStatus.running || !watchStatus.state) { + streams.stdout.write("Watch: not running\n"); + } else { + streams.stdout.write(`Watch: running (pid ${watchStatus.state.pid})\n`); + streams.stdout.write(`Started at: ${watchStatus.state.started_at}\n`); + streams.stdout.write( + `Auto-switch: ${watchStatus.state.auto_switch ? "enabled" : "disabled"}\n`, + ); + streams.stdout.write(`Log: ${watchStatus.state.log_path}\n`); + } + return 0; + } + + if (stop) { + const stopResult = await watchProcessManager.stop(); + if (!stopResult.stopped || !stopResult.state) { + streams.stdout.write("Watch: not running\n"); + } else { + streams.stdout.write(`Stopped background watch (pid ${stopResult.state.pid}).\n`); + } + return 0; + } + + const desktopRunning = await desktopLauncher.isManagedDesktopRunning(); + if (!desktopRunning && detach) { + throw new Error( + "Detached CLI watch is not yet supported. Run \"codexm watch\" in the foreground instead.", + ); + } + + if (!desktopRunning) { + return await runCliWatchSession({ + store, + desktopLauncher, + streams, + interruptSignal, + autoSwitch, + debug, + debugLog, + watchQuotaMinReadIntervalMs, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + }); + } + + if (detach) { + const detachedState = await watchProcessManager.startDetached({ + autoSwitch, + debug, + }); + streams.stdout.write(`Started background watch (pid ${detachedState.pid}).\n`); + streams.stdout.write(`Log: ${detachedState.log_path}\n`); + return 0; + } + + return await runManagedDesktopWatchSession({ + store, + desktopLauncher, + streams, + interruptSignal, + autoSwitch, + debug, + debugLog, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + watchQuotaMinReadIntervalMs, + watchQuotaIdleReadIntervalMs, + }); +} diff --git a/src/desktop-managed-state.ts b/src/desktop-managed-state.ts new file mode 100644 index 0000000..b73ccb3 --- /dev/null +++ b/src/desktop-managed-state.ts @@ -0,0 +1,182 @@ +import { copyFile, readFile, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import { getSnapshotEmail, parseAuthSnapshot } from "./auth-snapshot.js"; +import type { AccountStore } from "./account-store.js"; +import { + DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, + type CodexDesktopLauncher, + type ManagedCodexDesktopState, + type RunningCodexDesktop, +} from "./codex-desktop-launch.js"; +import { isCodexDesktopCommand, type CodexmPlatform } from "./platform.js"; + +interface CliStreams { + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return false; + } + + throw error; + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function confirmDesktopRelaunch( + streams: CliStreams, + prompt: string, +): Promise { + if (!streams.stdin.isTTY) { + throw new Error("Refusing to relaunch Codex Desktop in a non-interactive terminal."); + } + + streams.stdout.write(prompt); + + return await new Promise((resolve) => { + const cleanup = () => { + streams.stdin.off("data", onData); + streams.stdin.pause(); + }; + + const onData = (buffer: Buffer) => { + const answer = buffer.toString("utf8").trim().toLowerCase(); + cleanup(); + streams.stdout.write("\n"); + resolve(answer === "y" || answer === "yes"); + }; + + streams.stdin.resume(); + streams.stdin.on("data", onData); + }); +} + +export function isRunningDesktopFromApp( + app: RunningCodexDesktop, + appPath: string, + platform: CodexmPlatform = "darwin", +): boolean { + if (platform === "darwin") { + return app.command.includes(`${appPath}/Contents/MacOS/Codex`); + } + + return isCodexDesktopCommand(app.command, platform); +} + +export function isOnlyManagedDesktopInstanceRunning( + runningApps: RunningCodexDesktop[], + managedState: ManagedCodexDesktopState | null, + platform: CodexmPlatform = "darwin", +): boolean { + if (!managedState || runningApps.length === 0) { + return false; + } + + return ( + runningApps.length === 1 && + runningApps[0].pid === managedState.pid && + isRunningDesktopFromApp(runningApps[0], managedState.app_path, platform) + ); +} + +export async function resolveManagedDesktopState( + desktopLauncher: CodexDesktopLauncher, + appPath: string, + existingApps: RunningCodexDesktop[], + platform: CodexmPlatform, +): Promise { + const existingPids = new Set(existingApps.map((app) => app.pid)); + + for (let attempt = 0; attempt < 10; attempt += 1) { + const runningApps = await desktopLauncher.listRunningApps(); + const launchedApp = + runningApps + .filter( + (app) => + isRunningDesktopFromApp(app, appPath, platform) && !existingPids.has(app.pid), + ) + .sort((left, right) => right.pid - left.pid)[0] ?? + runningApps + .filter((app) => isRunningDesktopFromApp(app, appPath, platform)) + .sort((left, right) => right.pid - left.pid)[0] ?? + null; + + if (launchedApp) { + return { + pid: launchedApp.pid, + app_path: appPath, + remote_debugging_port: DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, + managed_by_codexm: true, + started_at: new Date().toISOString(), + }; + } + + await sleep(300); + } + + return null; +} + +export async function restoreLaunchBackup( + store: AccountStore, + backupPath: string | null, +): Promise { + if (backupPath && await pathExists(backupPath)) { + await copyFile(backupPath, store.paths.currentAuthPath); + } else { + await rm(store.paths.currentAuthPath, { force: true }); + } + + const configBackupPath = join(store.paths.backupsDir, "last-active-config.toml"); + if (await pathExists(configBackupPath)) { + await copyFile(configBackupPath, store.paths.currentConfigPath); + } else { + await rm(store.paths.currentConfigPath, { force: true }); + } +} + +export async function shouldSkipManagedDesktopRefresh( + store: AccountStore, + desktopLauncher: CodexDesktopLauncher, + debugLog?: (message: string) => void, +): Promise { + try { + const runtimeAccount = await desktopLauncher.readManagedCurrentAccount(); + if (!runtimeAccount?.email || !runtimeAccount.auth_mode) { + debugLog?.("switch: managed Desktop runtime identity unavailable"); + return false; + } + + const rawAuth = await readFile(store.paths.currentAuthPath, "utf8"); + const currentSnapshot = parseAuthSnapshot(rawAuth); + const currentEmail = getSnapshotEmail(currentSnapshot); + if (!currentEmail) { + debugLog?.("switch: current auth email unavailable"); + return false; + } + + const sameAuthMode = runtimeAccount.auth_mode === currentSnapshot.auth_mode; + const sameEmail = runtimeAccount.email.trim().toLowerCase() === currentEmail.trim().toLowerCase(); + if (!sameAuthMode || !sameEmail) { + debugLog?.("switch: managed Desktop runtime differs from target auth"); + return false; + } + + debugLog?.("switch: skipping managed Desktop refresh because runtime already matches target auth"); + return true; + } catch (error) { + debugLog?.(`switch: managed Desktop refresh skip check failed: ${(error as Error).message}`); + return false; + } +} diff --git a/src/main.ts b/src/main.ts index 5177878..ee486ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,31 +1,15 @@ -import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { stdin as defaultStdin, stdout as defaultStdout, stderr as defaultStderr } from "node:process"; -import { join } from "node:path"; -import dayjs from "dayjs"; -import timezone from "dayjs/plugin/timezone.js"; -import utc from "dayjs/plugin/utc.js"; import packageJson from "../package.json"; -import { getSnapshotEmail, maskAccountId, parseAuthSnapshot } from "./auth-snapshot.js"; +import { getSnapshotAccountId, getSnapshotEmail, maskAccountId, parseAuthSnapshot } from "./auth-snapshot.js"; import { AccountStore, - type AccountQuotaSummary, createAccountStore, } from "./account-store.js"; -import { - type CodexDesktopLauncher, - type ManagedCodexDesktopState, - type ManagedQuotaSignal, - type ManagedWatchActivitySignal, - type ManagedWatchStatusEvent, - type RuntimeQuotaSnapshot, - type RunningCodexDesktop, - DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, - DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, -} from "./codex-desktop-launch.js"; +import { type CodexDesktopLauncher } from "./codex-desktop-launch.js"; import { createWatchProcessManager, - type WatchProcessState, type WatchProcessManager, } from "./watch-process.js"; import { @@ -44,11 +28,7 @@ import { import { describeAutoSwitchNoop, describeAutoSwitchSelection, - isTerminalWatchQuota, - rankAutoSwitchCandidates, toCliQuotaSummary, - toCliQuotaSummaryFromRuntimeQuota, - type AutoSwitchCandidate, } from "./cli/quota.js"; import { writeJson } from "./cli/output.js"; import { @@ -65,27 +45,29 @@ import { handleListCommand, } from "./commands/inspection.js"; import { - appendWatchQuotaHistory, - createWatchHistoryStore, -} from "./watch-history.js"; - + handleLaunchCommand, + handleWatchCommand, +} from "./commands/desktop.js"; import { - createCliProcessManager, - type CliProcessManager, -} from "./codex-cli-watcher.js"; + describeBusySwitchLock, + performAutoSwitch, + refreshManagedDesktopAfterSwitch, + stripManagedDesktopWarning, + tryAcquireSwitchLock, +} from "./switching.js"; + import { runCodexWithAutoRestart, } from "./codex-cli-runner.js"; import { createPlatformDesktopLauncher, } from "./platform-desktop-adapter.js"; -import { getPlatform, isCodexDesktopCommand, type CodexmPlatform } from "./platform.js"; +import { + shouldSkipManagedDesktopRefresh, +} from "./desktop-managed-state.js"; export { rankAutoSwitchCandidates } from "./cli/quota.js"; -dayjs.extend(utc); -dayjs.extend(timezone); - interface CliStreams { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream; @@ -103,370 +85,6 @@ interface RunCliOptions extends Partial { watchQuotaMinReadIntervalMs?: number; watchQuotaIdleReadIntervalMs?: number; } - -interface AutoSwitchSelection { - refreshResult: Awaited>; - selected: AutoSwitchCandidate; - candidates: AutoSwitchCandidate[]; - quota: ReturnType | null; - warnings: string[]; -} - -interface SwitchLockOwner { - pid: number; - command: string; - started_at: string; -} -const SWITCH_LOCKS_DIR_NAME = "locks"; -const SWITCH_LOCK_DIR_NAME = "switch.lock"; - -const NON_MANAGED_DESKTOP_WARNING_PREFIX = - '"codexm switch" updates local auth, but running Codex Desktop may still use the previous login state.'; -const NON_MANAGED_DESKTOP_FOLLOWUP_WARNING = - 'Use "codexm launch" to start Codex Desktop with the selected auth; future switches can apply immediately to that session.'; -const INTERNAL_LAUNCH_REFUSAL_MESSAGE = - 'Refusing to run "codexm launch" from inside Codex Desktop because quitting the app would terminate this session. Run this command from an external terminal instead.'; - -function stripManagedDesktopWarning(warnings: string[]): string[] { - return warnings.filter( - (warning) => - warning !== NON_MANAGED_DESKTOP_WARNING_PREFIX && - warning !== NON_MANAGED_DESKTOP_FOLLOWUP_WARNING, - ); -} - -const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_DELAY_MS = 1_000; -const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_INTERVAL_MS = 5_000; -const WATCH_AUTO_SWITCH_TIMEOUT_MS = 600_000; -const DEFAULT_WATCH_QUOTA_MIN_READ_INTERVAL_MS = 30_000; -const DEFAULT_WATCH_QUOTA_IDLE_READ_INTERVAL_MS = 120_000; - -function startManagedDesktopWaitReporter( - stream: NodeJS.WriteStream, - options: { - delayMs?: number; - intervalMs?: number; - } = {}, -): { - stop: (result: "success" | "cancelled") => void; -} { - const delayMs = options.delayMs ?? DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_DELAY_MS; - const intervalMs = options.intervalMs ?? DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_INTERVAL_MS; - const startedAt = Date.now(); - let started = false; - let intervalHandle: NodeJS.Timeout | null = null; - - const timeoutHandle = setTimeout(() => { - started = true; - stream.write( - "Waiting for the current Codex Desktop thread to finish before applying the switch...\n", - ); - - intervalHandle = setInterval(() => { - const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000)); - stream.write( - `Still waiting for the current Codex Desktop thread to finish (${elapsedSeconds}s elapsed)...\n`, - ); - }, intervalMs); - intervalHandle.unref?.(); - }, delayMs); - timeoutHandle.unref?.(); - - return { - stop: (result) => { - clearTimeout(timeoutHandle); - if (intervalHandle) { - clearInterval(intervalHandle); - } - - if (started && result === "success") { - stream.write("Applied the switch to the managed Codex Desktop session.\n"); - } - }, - }; -} - -async function refreshManagedDesktopAfterSwitch( - warnings: string[], - desktopLauncher: CodexDesktopLauncher, - options: { - force?: boolean; - signal?: AbortSignal; - statusStream?: NodeJS.WriteStream; - statusDelayMs?: number; - statusIntervalMs?: number; - timeoutMs?: number; - } = {}, -): Promise { - let reporter: ReturnType | null = null; - if (options.force !== true && options.statusStream) { - try { - if (await desktopLauncher.isManagedDesktopRunning()) { - reporter = startManagedDesktopWaitReporter(options.statusStream, { - delayMs: options.statusDelayMs, - intervalMs: options.statusIntervalMs, - }); - } - } catch { - // Keep status reporting best-effort, same as the rest of Desktop inspection. - } - } - - try { - if ( - await desktopLauncher.applyManagedSwitch({ - force: options.force === true, - signal: options.signal, - timeoutMs: options.timeoutMs ?? DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, - }) - ) { - reporter?.stop("success"); - return; - } - } catch (error) { - reporter?.stop("cancelled"); - if ((error as Error).name === "AbortError") { - warnings.push( - "Refreshing the running codexm-managed Codex Desktop session was interrupted after the local auth switched. Relaunch Codex Desktop or rerun switch --force to apply the change immediately.", - ); - return; - } - - if (options.force === true) { - try { - await desktopLauncher.quitRunningApps({ force: true }); - warnings.push( - `Force-killed the running codexm-managed Codex Desktop session because the immediate refresh path failed: ${(error as Error).message} Relaunch Codex Desktop to continue with the new auth.`, - ); - return; - } catch (fallbackError) { - warnings.push( - `Failed to refresh the running codexm-managed Codex Desktop session: ${(error as Error).message} Fallback force-kill also failed: ${(fallbackError as Error).message}`, - ); - return; - } - } - - warnings.push( - `Failed to refresh the running codexm-managed Codex Desktop session: ${(error as Error).message}`, - ); - return; - } - - reporter?.stop("cancelled"); - - try { - const runningApps = await desktopLauncher.listRunningApps(); - if (runningApps.length === 0) { - return; - } - - if (runningApps.length > 0) { - warnings.push(NON_MANAGED_DESKTOP_WARNING_PREFIX); - warnings.push(NON_MANAGED_DESKTOP_FOLLOWUP_WARNING); - } - } catch { - // Keep Desktop detection best-effort so switch success does not depend on local process inspection. - } -} - -async function shouldSkipManagedDesktopRefresh( - store: AccountStore, - desktopLauncher: CodexDesktopLauncher, - debugLog?: (message: string) => void, -): Promise { - try { - const runtimeAccount = await desktopLauncher.readManagedCurrentAccount(); - if (!runtimeAccount?.email || !runtimeAccount.auth_mode) { - debugLog?.("switch: managed Desktop runtime identity unavailable"); - return false; - } - - const rawAuth = await readFile(store.paths.currentAuthPath, "utf8"); - const currentSnapshot = parseAuthSnapshot(rawAuth); - const currentEmail = getSnapshotEmail(currentSnapshot); - if (!currentEmail) { - debugLog?.("switch: current auth email unavailable"); - return false; - } - - const sameAuthMode = runtimeAccount.auth_mode === currentSnapshot.auth_mode; - const sameEmail = runtimeAccount.email.trim().toLowerCase() === currentEmail.trim().toLowerCase(); - if (!sameAuthMode || !sameEmail) { - debugLog?.("switch: managed Desktop runtime differs from target auth"); - return false; - } - - debugLog?.("switch: skipping managed Desktop refresh because runtime already matches target auth"); - return true; - } catch (error) { - debugLog?.(`switch: managed Desktop refresh skip check failed: ${(error as Error).message}`); - return false; - } -} - -function describeWatchQuotaUpdate(quota: ReturnType | null): string { - if (!quota) { - return "Quota update: Usage: unavailable"; - } - - if (quota.refresh_status !== "ok") { - if (quota.refresh_status === "unsupported") { - return "Quota update: Usage: unsupported"; - } - - return `Quota update: Usage: ${quota.refresh_status}${quota.error_message ? ` | ${quota.error_message}` : ""}`; - } - - return `Quota update: Usage: ${quota.available ?? "unknown"} | 5H ${quota.five_hour?.used_percent ?? "-"}% used | 1W ${quota.one_week?.used_percent ?? "-"}% used`; -} - -function formatWatchLogLine(message: string): string { - return `[${dayjs().format("HH:mm:ss")}] ${message}`; -} - -function formatWatchField(key: string, value: string | number): string { - if (typeof value === "number") { - return `${key}=${value}`; - } - - return `${key}=${JSON.stringify(value)}`; -} - -function computeRemainingPercent(usedPercent: number | undefined): number | null { - if (typeof usedPercent !== "number") { - return null; - } - - return Math.max(0, 100 - usedPercent); -} - -function describeWatchQuotaEvent( - accountLabel: string, - quota: ReturnType | null, -): string { - if (!quota || quota.refresh_status !== "ok") { - return `quota ${formatWatchField("account", accountLabel)} status=${ - quota?.refresh_status ?? "unavailable" - }`; - } - - return [ - "quota", - formatWatchField("account", accountLabel), - `usage=${quota.available ?? "unknown"}`, - `5H=${computeRemainingPercent(quota.five_hour?.used_percent) ?? "-"}% left`, - `1W=${computeRemainingPercent(quota.one_week?.used_percent) ?? "-"}% left`, - ].join(" "); -} - -function describeWatchStatusEvent(accountLabel: string, event: ManagedWatchStatusEvent): string { - if (event.type === "reconnected") { - return [ - "reconnect-ok", - formatWatchField("account", accountLabel), - formatWatchField("attempt", event.attempt), - ].join(" "); - } - - const fields = [ - "reconnect-lost", - formatWatchField("account", accountLabel), - formatWatchField("attempt", event.attempt), - ]; - if (event.error) { - fields.push(formatWatchField("error", event.error)); - } - return fields.join(" "); -} - -function describeWatchAutoSwitchEvent(fromAccount: string, toAccount: string, warnings: string[]): string { - const fields = [ - "auto-switch", - formatWatchField("from", fromAccount), - formatWatchField("to", toAccount), - ]; - if (warnings.length > 0) { - fields.push(formatWatchField("warnings", warnings.length)); - } - return fields.join(" "); -} - -function describeWatchAutoSwitchSkippedEvent(accountLabel: string, reason: string): string { - return [ - "auto-switch-skipped", - formatWatchField("account", accountLabel), - `reason=${reason}`, - ].join(" "); -} - -async function resolveWatchAccountLabel(store: AccountStore): Promise { - try { - const current = await store.getCurrentStatus(); - if (current.matched_accounts.length === 1) { - return current.matched_accounts[0]; - } - } catch { - // Keep watch logging best-effort when local current-state inspection fails. - } - - return "current"; -} - -async function resolveManagedAccountByName( - store: AccountStore, - name: string, -): Promise>["accounts"][number] | null> { - const { accounts } = await store.listAccounts(); - return accounts.find((account) => account.name === name) ?? null; -} - -async function ensureDetachedWatch( - watchProcessManager: WatchProcessManager, - options: { autoSwitch: boolean; debug: boolean }, -): Promise< - | { action: "started" | "restarted"; state: WatchProcessState } - | { action: "reused"; state: WatchProcessState } -> { - const status = await watchProcessManager.getStatus(); - if (status.running && status.state) { - if ( - status.state.auto_switch === options.autoSwitch && - status.state.debug === options.debug - ) { - return { - action: "reused", - state: status.state, - }; - } - - await watchProcessManager.stop(); - return { - action: "restarted", - state: await watchProcessManager.startDetached(options), - }; - } - - return { - action: "started", - state: await watchProcessManager.startDetached(options), - }; -} - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === "ENOENT") { - return false; - } - - throw error; - } -} - function createDebugLogger( stream: NodeJS.WriteStream, enabled: boolean, @@ -480,413 +98,28 @@ function createDebugLogger( }; } -async function tryReadManagedDesktopQuota( - desktopLauncher: CodexDesktopLauncher, - debugLog?: (message: string) => void, - fallbackQuota?: RuntimeQuotaSnapshot | null, -): Promise | null> { - if (fallbackQuota) { - debugLog?.("watch: using quota carried by Desktop bridge signal"); - return toCliQuotaSummaryFromRuntimeQuota(fallbackQuota); - } - - try { - const quota = await desktopLauncher.readManagedCurrentQuota(); - if (!quota) { - debugLog?.("watch: managed Desktop quota unavailable"); - return null; - } - - debugLog?.("watch: using managed Desktop quota"); - return toCliQuotaSummaryFromRuntimeQuota(quota); - } catch (error) { - debugLog?.(`watch: managed Desktop quota read failed: ${(error as Error).message}`); - return null; - } -} - -interface AutoSwitchExecutionResult { - refreshResult: { - successes: AccountQuotaSummary[]; - failures: Array<{ name: string; error: string }>; - }; - selected: AutoSwitchCandidate; - candidates: AutoSwitchCandidate[]; - quota: ReturnType | null; - skipped: boolean; - result: Awaited> | null; - warnings: string[]; -} - -async function performAutoSwitch( - store: AccountStore, - desktopLauncher: CodexDesktopLauncher, - options: { - dryRun: boolean; - force: boolean; - signal?: AbortSignal; - statusStream?: NodeJS.WriteStream; - statusDelayMs?: number; - statusIntervalMs?: number; - timeoutMs?: number; - debugLog?: (message: string) => void; - }, -): Promise { - options.debugLog?.(`switch: mode=auto dry_run=${options.dryRun} force=${options.force}`); - const selection = await selectAutoSwitchAccount(store); - const { refreshResult, selected, candidates, quota, warnings } = selection; - if (options.dryRun) { - options.debugLog?.( - `switch: auto-selected target=${selected.name} candidates=${candidates.length} warnings=${warnings.length} dry_run=true`, - ); - return { - refreshResult, - selected, - candidates, - quota, - skipped: false, - result: null, - warnings, - }; - } - - return performSelectedAutoSwitch(store, desktopLauncher, selection, options); -} - -async function selectAutoSwitchAccount(store: AccountStore): Promise { - const refreshResult = await store.refreshAllQuotas(); - const candidates = rankAutoSwitchCandidates(refreshResult.successes); - if (candidates.length === 0) { - throw new Error("No auto-switch candidate has usable 5H or 1W quota data available."); - } - - const selected = candidates[0]; - const selectedQuota = - refreshResult.successes.find((account) => account.name === selected.name) ?? null; - const quota = selectedQuota ? toCliQuotaSummary(selectedQuota) : null; - const warnings = refreshResult.failures.map((failure) => `${failure.name}: ${failure.error}`); - - return { - refreshResult, - selected, - candidates, - quota, - warnings, - }; -} - -async function performSelectedAutoSwitch( +async function readCurrentRunAccountMetadata( store: AccountStore, - desktopLauncher: CodexDesktopLauncher, - selection: AutoSwitchSelection, - options: { - dryRun: boolean; - force: boolean; - signal?: AbortSignal; - statusStream?: NodeJS.WriteStream; - statusDelayMs?: number; - statusIntervalMs?: number; - timeoutMs?: number; - debugLog?: (message: string) => void; - }, -): Promise { - const { refreshResult, selected, candidates, quota, warnings } = selection; - - const currentStatus = await store.getCurrentStatus(); - if ( - selected.available === "available" && - currentStatus.matched_accounts.includes(selected.name) - ) { - options.debugLog?.( - `switch: auto-selected target=${selected.name} candidates=${candidates.length} skipped=already_current_best`, - ); - return { - refreshResult, - selected, - candidates, - quota, - skipped: true, - result: null, - warnings, - }; - } - - const result = await store.switchAccount(selected.name); - for (const warning of warnings) { - result.warnings.push(warning); - } - result.warnings = stripManagedDesktopWarning(result.warnings); - - await refreshManagedDesktopAfterSwitch(result.warnings, desktopLauncher, { - force: options.force, - signal: options.signal, - statusStream: options.statusStream, - statusDelayMs: options.statusDelayMs, - statusIntervalMs: options.statusIntervalMs, - timeoutMs: options.timeoutMs, - }); - options.debugLog?.( - `switch: completed mode=auto target=${result.account.name} candidates=${candidates.length} warnings=${result.warnings.length}`, - ); - - return { - refreshResult, - selected, - candidates, - quota, - skipped: false, - result, - warnings: result.warnings, - }; -} - -function getSwitchLockDir(store: AccountStore): string { - return join(store.paths.codexTeamDir, SWITCH_LOCKS_DIR_NAME, SWITCH_LOCK_DIR_NAME); -} - -function getSwitchLockOwnerPath(store: AccountStore): string { - return join(getSwitchLockDir(store), "owner.json"); -} - -function isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) { - return false; - } - - try { - process.kill(pid, 0); - return true; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === "ESRCH") { - return false; - } - return true; - } -} - -async function readSwitchLockOwner(store: AccountStore): Promise { +): Promise<{ accountId: string | null; email: string | null }> { try { - const raw = await readFile(getSwitchLockOwnerPath(store), "utf8"); - const parsed = JSON.parse(raw) as Partial; - if ( - typeof parsed.pid === "number" && - typeof parsed.command === "string" && - typeof parsed.started_at === "string" - ) { - return { - pid: parsed.pid, - command: parsed.command, - started_at: parsed.started_at, - }; - } - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== "ENOENT") { - return null; - } - } - - return null; -} - -async function tryAcquireSwitchLock( - store: AccountStore, - command: string, -): Promise< - | { acquired: true; lockPath: string; release: () => Promise } - | { acquired: false; lockPath: string; owner: SwitchLockOwner | null } -> { - const locksDir = join(store.paths.codexTeamDir, SWITCH_LOCKS_DIR_NAME); - const lockPath = getSwitchLockDir(store); - const ownerPath = getSwitchLockOwnerPath(store); - await mkdir(locksDir, { recursive: true, mode: 0o700 }); - - const tryCreateLock = async (): Promise => { - try { - await mkdir(lockPath, { mode: 0o700 }); - return true; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === "EEXIST") { - return false; - } - throw error; - } - }; - - let created = await tryCreateLock(); - if (!created) { - const existingOwner = await readSwitchLockOwner(store); - if (!existingOwner || !isProcessAlive(existingOwner.pid)) { - await rm(lockPath, { recursive: true, force: true }); - created = await tryCreateLock(); - } - } - - if (!created) { + const rawAuth = await readFile(store.paths.currentAuthPath, "utf8"); + const snapshot = parseAuthSnapshot(rawAuth); return { - acquired: false, - lockPath, - owner: await readSwitchLockOwner(store), - }; - } - - const owner: SwitchLockOwner = { - pid: process.pid, - command, - started_at: new Date().toISOString(), - }; - - try { - await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - } catch (error) { - await rm(lockPath, { recursive: true, force: true }).catch(() => undefined); - throw error; - } - - return { - acquired: true, - lockPath, - release: async () => { - await rm(lockPath, { recursive: true, force: true }); - }, - }; -} - -function describeBusySwitchLock(lockPath: string, owner: SwitchLockOwner | null): string { - let message = `Another codexm switch or launch operation is already in progress. Lock: ${lockPath}`; - if (owner) { - message += ` (pid ${owner.pid}, command ${JSON.stringify(owner.command)}, started ${owner.started_at})`; - } - return message; -} - -async function confirmDesktopRelaunch( - streams: CliStreams, - prompt: string, -): Promise { - if (!streams.stdin.isTTY) { - throw new Error("Refusing to relaunch Codex Desktop in a non-interactive terminal."); - } - - streams.stdout.write(prompt); - - return await new Promise((resolve) => { - const cleanup = () => { - streams.stdin.off("data", onData); - streams.stdin.pause(); + accountId: getSnapshotAccountId(snapshot) || null, + email: getSnapshotEmail(snapshot) ?? null, }; - - const onData = (buffer: Buffer) => { - const answer = buffer.toString("utf8").trim().toLowerCase(); - cleanup(); - streams.stdout.write("\n"); - resolve(answer === "y" || answer === "yes"); + } catch { + return { + accountId: null, + email: null, }; - - streams.stdin.resume(); - streams.stdin.on("data", onData); - }); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -let _cachedPlatformForDesktopCheck: CodexmPlatform | null = null; -async function getDesktopCheckPlatform(): Promise { - if (!_cachedPlatformForDesktopCheck) { - _cachedPlatformForDesktopCheck = await getPlatform(); - } - return _cachedPlatformForDesktopCheck; -} - -function isRunningDesktopFromApp( - app: RunningCodexDesktop, - appPath: string, - platform: CodexmPlatform = "darwin", -): boolean { - if (platform === "darwin") { - return app.command.includes(`${appPath}/Contents/MacOS/Codex`); - } - // On Linux/WSL, use platform-aware check - return isCodexDesktopCommand(app.command, platform); -} - -function isOnlyManagedDesktopInstanceRunning( - runningApps: RunningCodexDesktop[], - managedState: ManagedCodexDesktopState | null, - platform: CodexmPlatform = "darwin", -): boolean { - if (!managedState || runningApps.length === 0) { - return false; - } - - return ( - runningApps.length === 1 && - runningApps[0].pid === managedState.pid && - isRunningDesktopFromApp(runningApps[0], managedState.app_path, platform) - ); -} - -async function resolveManagedDesktopState( - desktopLauncher: CodexDesktopLauncher, - appPath: string, - existingApps: RunningCodexDesktop[], -): Promise { - const existingPids = new Set(existingApps.map((app) => app.pid)); - - for (let attempt = 0; attempt < 10; attempt += 1) { - const runningApps = await desktopLauncher.listRunningApps(); - const launchedApp = - runningApps - .filter( - (app) => - isRunningDesktopFromApp(app, appPath, launchPlatform) && !existingPids.has(app.pid), - ) - .sort((left, right) => right.pid - left.pid)[0] ?? - runningApps - .filter((app) => isRunningDesktopFromApp(app, appPath, launchPlatform)) - .sort((left, right) => right.pid - left.pid)[0] ?? - null; - - if (launchedApp) { - return { - pid: launchedApp.pid, - app_path: appPath, - remote_debugging_port: DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, - managed_by_codexm: true, - started_at: new Date().toISOString(), - }; - } - - await sleep(300); } - - return null; } -async function restoreLaunchBackup( - store: AccountStore, - backupPath: string | null, -): Promise { - if (backupPath && await pathExists(backupPath)) { - await copyFile(backupPath, store.paths.currentAuthPath); - } else { - await rm(store.paths.currentAuthPath, { force: true }); - } - - const configBackupPath = join(store.paths.backupsDir, "last-active-config.toml"); - if (await pathExists(configBackupPath)) { - await copyFile(configBackupPath, store.paths.currentConfigPath); - } else { - await rm(store.paths.currentConfigPath, { force: true }); - } -} +const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_DELAY_MS = 1_000; +const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_INTERVAL_MS = 5_000; +const DEFAULT_WATCH_QUOTA_MIN_READ_INTERVAL_MS = 30_000; +const DEFAULT_WATCH_QUOTA_IDLE_READ_INTERVAL_MS = 120_000; export async function runCli( argv: string[], @@ -1144,21 +377,7 @@ export async function runCli( throw new Error("Usage: codexm switch [--force]"); } - // --force is only meaningful when a managed Desktop session is running. - // In CLI mode, downgrade to a normal switch with a warning. - let effectiveForce = force; - if (force) { - const switchDesktopRunning = await desktopLauncher.isManagedDesktopRunning(); - if (!switchDesktopRunning) { - effectiveForce = false; - streams.stderr.write( - "Warning: --force is only meaningful with a managed Desktop session. " + - "In CLI mode, use \"codexm run\" for seamless auth hot-switching.\n", - ); - } - } - - debugLog(`switch: mode=manual target=${name} force=${force} effectiveForce=${effectiveForce}`); + debugLog(`switch: mode=manual target=${name} force=${force}`); const switchCommand = `switch ${name}`; const lock = await tryAcquireSwitchLock(store, switchCommand); if (!lock.acquired) { @@ -1175,13 +394,19 @@ export async function runCli( debugLog, ); if (!skipDesktopRefresh) { - await refreshManagedDesktopAfterSwitch(switched.warnings, desktopLauncher, { - force: effectiveForce, + const refreshOutcome = await refreshManagedDesktopAfterSwitch(switched.warnings, desktopLauncher, { + force, signal: interruptSignal, statusStream: streams.stderr, statusDelayMs: managedDesktopWaitStatusDelayMs, statusIntervalMs: managedDesktopWaitStatusIntervalMs, }); + if (force && refreshOutcome === "none") { + streams.stderr.write( + "Warning: --force is only meaningful with a managed Desktop session. " + + "In CLI mode, use \"codexm run\" for seamless auth hot-switching.\n", + ); + } } return switched; } finally { @@ -1233,719 +458,33 @@ export async function runCli( } case "launch": { - const name = parsed.positionals[0] ?? null; - const auto = parsed.flags.has("--auto"); - const watch = parsed.flags.has("--watch"); - const noAutoSwitch = parsed.flags.has("--no-auto-switch"); - - if ( - parsed.positionals.length > 1 || - (auto && name) || - (noAutoSwitch && !watch) - ) { - throw new Error("Usage: codexm launch [name] [--auto] [--watch] [--no-auto-switch] [--json]"); - } - - if (await desktopLauncher.isRunningInsideDesktopShell()) { - throw new Error(INTERNAL_LAUNCH_REFUSAL_MESSAGE); - } - - const launchPlatform = await getPlatform(); - - // Desktop launch is only supported on macOS. - // On WSL/Linux, guide users to the CLI-first workflow. - if (launchPlatform !== "darwin") { - throw new Error( - launchPlatform === "wsl" - ? "codexm launch is not supported on WSL. Use \"codexm run [-- ...args]\" to start codex with auto-restart on auth changes." - : "codexm launch is not supported on Linux. Use \"codexm run [-- ...args]\" to start codex with auto-restart on auth changes.", - ); - } - - const warnings: string[] = []; - const watchAutoSwitch = !noAutoSwitch; - const appPath = await desktopLauncher.findInstalledApp(); - if (!appPath) { - throw new Error( - "Codex Desktop not found. Install from https://codex.openai.com or check /Applications/Codex.app.", - ); - } - debugLog(`launch: requested_account=${name ?? "current"}`); - debugLog(`launch: using app path ${appPath}`); - - const runningApps = await desktopLauncher.listRunningApps(); - debugLog(`launch: running_desktop_instances=${runningApps.length}`); - if (runningApps.length > 0) { - const managedDesktopState = await desktopLauncher.readManagedState(); - const canRelaunchGracefully = isOnlyManagedDesktopInstanceRunning( - runningApps, - managedDesktopState, - launchPlatform, - ); - const confirmed = await confirmDesktopRelaunch( - streams, - canRelaunchGracefully - ? "Codex Desktop is already running. Close it and relaunch with the selected auth? [y/N] " - : "Codex Desktop is already running outside codexm. Force-kill it and relaunch with the selected auth? [y/N] ", - ); - if (!confirmed) { - if (json) { - writeJson(streams.stdout, { - ok: false, - action: "launch", - cancelled: true, - }); - } else { - streams.stdout.write("Aborted.\n"); - } - return 1; - } - - await desktopLauncher.quitRunningApps({ force: !canRelaunchGracefully }); - } - - let switchedAccount: Awaited>["account"] | null = - null; - let switchBackupPath: string | null = null; - const requestedTargetName = name; - if (auto || requestedTargetName) { - const launchCommand = auto ? "launch --auto" : `launch ${requestedTargetName}`; - const lock = await tryAcquireSwitchLock(store, launchCommand); - if (!lock.acquired) { - throw new Error(describeBusySwitchLock(lock.lockPath, lock.owner)); - } - - try { - const targetName = auto - ? (await selectAutoSwitchAccount(store)).selected.name - : requestedTargetName; - if (auto) { - debugLog(`launch: auto-selected account=${targetName ?? "current"}`); - } - const currentStatus = await store.getCurrentStatus(); - if (targetName && !currentStatus.matched_accounts.includes(targetName)) { - const switchResult = await store.switchAccount(targetName); - warnings.push(...stripManagedDesktopWarning(switchResult.warnings)); - switchedAccount = switchResult.account; - switchBackupPath = switchResult.backup_path; - debugLog(`launch: pre-switched account=${switchResult.account.name}`); - } else if (targetName) { - switchedAccount = await resolveManagedAccountByName(store, targetName); - } - - try { - await desktopLauncher.launch(appPath); - const managedState = await resolveManagedDesktopState( - desktopLauncher, - appPath, - runningApps, - ); - if (!managedState) { - await desktopLauncher.clearManagedState().catch(() => undefined); - throw new Error( - "Failed to confirm the newly launched Codex Desktop process for managed-session tracking.", - ); - } - await desktopLauncher.writeManagedState(managedState); - debugLog( - `launch: recorded managed desktop pid=${managedState.pid} port=${managedState.remote_debugging_port}`, - ); - } catch (error) { - if (switchedAccount) { - await restoreLaunchBackup(store, switchBackupPath).catch(() => undefined); - debugLog( - `launch: restored previous auth after failure for account=${switchedAccount.name}`, - ); - } - throw error; - } - } finally { - await lock.release(); - } - } else { - try { - await desktopLauncher.launch(appPath); - const managedState = await resolveManagedDesktopState( - desktopLauncher, - appPath, - runningApps, - ); - if (!managedState) { - await desktopLauncher.clearManagedState().catch(() => undefined); - throw new Error( - "Failed to confirm the newly launched Codex Desktop process for managed-session tracking.", - ); - } - await desktopLauncher.writeManagedState(managedState); - debugLog( - `launch: recorded managed desktop pid=${managedState.pid} port=${managedState.remote_debugging_port}`, - ); - } catch (error) { - throw error; - } - } - - let detachedWatchResult: - | Awaited> - | null = null; - if (watch) { - detachedWatchResult = await ensureDetachedWatch(watchProcessManager, { - autoSwitch: watchAutoSwitch, - debug, - }); - } - - if (json) { - writeJson(streams.stdout, { - ok: true, - action: "launch", - account: switchedAccount - ? { - name: switchedAccount.name, - account_id: switchedAccount.account_id, - user_id: switchedAccount.user_id ?? null, - identity: switchedAccount.identity, - auth_mode: switchedAccount.auth_mode, - } - : null, - launched_with_current_auth: switchedAccount === null, - app_path: appPath, - relaunched: runningApps.length > 0, - watch: - detachedWatchResult === null - ? null - : { - action: detachedWatchResult.action, - pid: detachedWatchResult.state.pid, - started_at: detachedWatchResult.state.started_at, - log_path: detachedWatchResult.state.log_path, - auto_switch: detachedWatchResult.state.auto_switch, - }, - warnings, - }); - } else { - if (switchedAccount) { - streams.stdout.write( - `Switched to "${switchedAccount.name}" (${maskAccountId(switchedAccount.identity)}).\n`, - ); - } - if (runningApps.length > 0) { - streams.stdout.write("Closed existing Codex Desktop instance and launched a new one.\n"); - } - streams.stdout.write( - switchedAccount - ? `Launched Codex Desktop with "${switchedAccount.name}" (${maskAccountId(switchedAccount.identity)}).\n` - : "Launched Codex Desktop with current auth.\n", - ); - if (detachedWatchResult) { - if (detachedWatchResult.action === "reused") { - streams.stdout.write( - `Background watch already running (pid ${detachedWatchResult.state.pid}).\n`, - ); - } else { - streams.stdout.write( - `Started background watch (pid ${detachedWatchResult.state.pid}).\n`, - ); - streams.stdout.write(`Log: ${detachedWatchResult.state.log_path}\n`); - } - } - for (const warning of warnings) { - streams.stdout.write(`Warning: ${warning}\n`); - } - } - return 0; + return await handleLaunchCommand({ + parsed, + json, + debug, + store, + desktopLauncher, + watchProcessManager, + streams, + debugLog, + }); } case "watch": { - if (parsed.positionals.length > 0) { - throw new Error("Usage: codexm watch [--no-auto-switch] [--detach] [--status] [--stop]"); - } - - const autoSwitch = !parsed.flags.has("--no-auto-switch"); - const detach = parsed.flags.has("--detach"); - const status = parsed.flags.has("--status"); - const stop = parsed.flags.has("--stop"); - const modeCount = [detach, status, stop].filter(Boolean).length; - - if (modeCount > 1 || ((status || stop) && parsed.flags.has("--no-auto-switch"))) { - throw new Error("Usage: codexm watch [--no-auto-switch] [--detach] [--status] [--stop]"); - } - - if (status) { - const watchStatus = await watchProcessManager.getStatus(); - if (!watchStatus.running || !watchStatus.state) { - streams.stdout.write("Watch: not running\n"); - } else { - streams.stdout.write(`Watch: running (pid ${watchStatus.state.pid})\n`); - streams.stdout.write(`Started at: ${watchStatus.state.started_at}\n`); - streams.stdout.write( - `Auto-switch: ${watchStatus.state.auto_switch ? "enabled" : "disabled"}\n`, - ); - streams.stdout.write(`Log: ${watchStatus.state.log_path}\n`); - } - return 0; - } - - if (stop) { - const stopResult = await watchProcessManager.stop(); - if (!stopResult.stopped || !stopResult.state) { - streams.stdout.write("Watch: not running\n"); - } else { - streams.stdout.write(`Stopped background watch (pid ${stopResult.state.pid}).\n`); - } - return 0; - } - - const desktopRunning = await desktopLauncher.isManagedDesktopRunning(); - - if (!desktopRunning && detach) { - throw new Error( - "Detached CLI watch is not yet supported. Run \"codexm watch\" in the foreground instead.", - ); - } - - // ── CLI-mode watch (no Desktop running) ── - if (!desktopRunning) { - const platform = await getPlatform(); - debugLog(`watch: no managed Desktop detected, entering CLI watch mode (platform=${platform})`); - streams.stderr.write( - `${formatWatchLogLine("No managed Codex Desktop session — entering CLI watch mode")}\n`, - ); - - const cliManager = createCliProcessManager({ - pollIntervalMs: watchQuotaMinReadIntervalMs, - }); - - // Discover and register existing CLI processes - const discovered = await cliManager.findRunningCliProcesses(); - if (discovered.length > 0) { - streams.stderr.write( - `${formatWatchLogLine(`Found ${discovered.length} running codex CLI process(es)`)}\n`, - ); - for (const proc of discovered) { - debugLog(`watch: discovered CLI process pid=${proc.pid} command=${proc.command}`); - } - } - - let cliWatchExitCode = 0; - let cliSwitchInFlight = false; - let cliLastSwitchStartedAt = 0; - let cliLastQuotaUpdateLine: string | null = null; - let cliCurrentAccountLabel = await resolveWatchAccountLabel(store); - const CLI_WATCH_SWITCH_COOLDOWN_MS = 5_000; - - const handleCliQuotaResult = async (options: { - requestId: string; - quota: ReturnType | null; - shouldAutoSwitch: boolean; - }) => { - const quota = options.quota; - const quotaUpdateLine = describeWatchQuotaEvent(cliCurrentAccountLabel, quota); - if (quotaUpdateLine !== cliLastQuotaUpdateLine) { - streams.stdout.write(`${formatWatchLogLine(quotaUpdateLine)}\n`); - cliLastQuotaUpdateLine = quotaUpdateLine; - } - - if (!autoSwitch || !options.shouldAutoSwitch) { - return; - } - - const lock = await tryAcquireSwitchLock(store, "watch-cli"); - if (!lock.acquired) { - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchSkippedEvent(cliCurrentAccountLabel, "lock-busy"), - )}\n`, - ); - return; - } - - const now = Date.now(); - if (cliSwitchInFlight || now - cliLastSwitchStartedAt < CLI_WATCH_SWITCH_COOLDOWN_MS) { - await lock.release(); - return; - } - - cliSwitchInFlight = true; - cliLastSwitchStartedAt = now; - - try { - const switchResult = await performAutoSwitch(store, desktopLauncher, { - dryRun: false, - force: false, - signal: interruptSignal, - statusStream: streams.stderr, - statusDelayMs: managedDesktopWaitStatusDelayMs, - statusIntervalMs: managedDesktopWaitStatusIntervalMs, - timeoutMs: WATCH_AUTO_SWITCH_TIMEOUT_MS, - debugLog, - }); - - if (switchResult.skipped) { - cliCurrentAccountLabel = switchResult.selected.name; - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchSkippedEvent(cliCurrentAccountLabel, "already-best"), - )}\n`, - ); - } else if (switchResult.result) { - const previousLabel = cliCurrentAccountLabel; - cliCurrentAccountLabel = switchResult.result.account.name; - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchEvent( - previousLabel, - cliCurrentAccountLabel, - switchResult.result.warnings, - ), - )}\n`, - ); - - // Restart CLI processes after account switch (SIGTERM → codexm run auto-respawns) - const restartResult = await cliManager.restartCliProcess({ - accountId: switchResult.selected.account_id ?? undefined, - signal: interruptSignal, - }); - if (restartResult.restarted > 0) { - streams.stderr.write( - `${formatWatchLogLine( - `Restarted ${restartResult.restarted} CLI process(es). Use "codexm run" for seamless auto-restart.`, - )}\n`, - ); - } - if (restartResult.failed > 0) { - streams.stderr.write( - `${formatWatchLogLine( - `Failed to restart ${restartResult.failed} CLI process(es)`, - )}\n`, - ); - } - } - - if (switchResult.refreshResult.failures.length > 0) { - cliWatchExitCode = 1; - } - } finally { - cliSwitchInFlight = false; - await lock.release(); - } - }; - - try { - await cliManager.watchCliQuotaSignals({ - pollIntervalMs: watchQuotaMinReadIntervalMs, - signal: interruptSignal, - debugLogger: debug - ? (line) => { - streams.stderr.write(`${line}\n`); - } - : undefined, - onStatus: async (event) => { - if (event.type === "disconnected") { - streams.stderr.write( - `${formatWatchLogLine(`CLI connection lost (attempt ${event.attempt}): ${event.error ?? "unknown"}`)}\n`, - ); - } else if (event.type === "reconnected") { - streams.stderr.write( - `${formatWatchLogLine("CLI connection established")}\n`, - ); - } - }, - onQuotaSignal: async (quotaSignal) => { - const quota = quotaSignal.quota - ? toCliQuotaSummaryFromRuntimeQuota(quotaSignal.quota) - : null; - await handleCliQuotaResult({ - requestId: quotaSignal.requestId, - quota, - shouldAutoSwitch: quotaSignal.shouldAutoSwitch, - }); - }, - }); - } catch (error) { - if (!interruptSignal?.aborted) { - streams.stderr.write(`Error: ${(error as Error).message}\n`); - cliWatchExitCode = 1; - } - } - - return cliWatchExitCode; - } - - - - if (detach) { - const detachedState = await watchProcessManager.startDetached({ - autoSwitch, - debug, - }); - streams.stdout.write(`Started background watch (pid ${detachedState.pid}).\n`); - streams.stdout.write(`Log: ${detachedState.log_path}\n`); - return 0; - } - - let watchExitCode = 0; - let switchInFlight = false; - let lastSwitchStartedAt = 0; - let lastQuotaUpdateLine: string | null = null; - let currentWatchAccountLabel = await resolveWatchAccountLabel(store); - const watchHistoryStore = createWatchHistoryStore(store.paths.codexTeamDir); - const WATCH_SWITCH_COOLDOWN_MS = 5_000; - - debugLog("watch: starting managed desktop quota watch"); - debugLog(`watch: auto-switch ${autoSwitch ? "enabled" : "disabled"}`); - - const handleQuotaReadResult = async (options: { - requestId: string; - quota: ReturnType | null; - shouldAutoSwitch: boolean; - }) => { - const quota = options.quota; - if (quota?.refresh_status === "ok") { - try { - await appendWatchQuotaHistory(watchHistoryStore, { - recordedAt: quota.fetched_at ?? new Date().toISOString(), - accountName: currentWatchAccountLabel, - accountId: quota.account_id, - identity: quota.identity, - planType: quota.plan_type, - available: quota.available, - fiveHour: quota.five_hour - ? { - usedPercent: quota.five_hour.used_percent, - windowSeconds: quota.five_hour.window_seconds, - resetAt: quota.five_hour.reset_at ?? null, - } - : null, - oneWeek: quota.one_week - ? { - usedPercent: quota.one_week.used_percent, - windowSeconds: quota.one_week.window_seconds, - resetAt: quota.one_week.reset_at ?? null, - } - : null, - }); - } catch (error) { - debugLog(`watch: failed to persist watch history: ${(error as Error).message}`); - } - } - const quotaUpdateLine = describeWatchQuotaEvent(currentWatchAccountLabel, quota); - if (quotaUpdateLine !== lastQuotaUpdateLine) { - streams.stdout.write(`${formatWatchLogLine(quotaUpdateLine)}\n`); - lastQuotaUpdateLine = quotaUpdateLine; - } else { - debugLog(`watch: quota output unchanged for requestId=${options.requestId}`); - } - if (!autoSwitch) { - return; - } - - if (!options.shouldAutoSwitch) { - debugLog( - `watch: skipping auto switch for requestId=${options.requestId} because the event is informational only`, - ); - return; - } - - const lock = await tryAcquireSwitchLock(store, "watch"); - if (!lock.acquired) { - debugLog(`watch: switch lock is busy at ${lock.lockPath}`); - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchSkippedEvent(currentWatchAccountLabel, "lock-busy"), - )}\n`, - ); - return; - } - - const now = Date.now(); - if (switchInFlight || now - lastSwitchStartedAt < WATCH_SWITCH_COOLDOWN_MS) { - await lock.release(); - debugLog( - `watch: skipped auto switch for requestId=${options.requestId} because another switch is already in progress`, - ); - return; - } - - switchInFlight = true; - lastSwitchStartedAt = now; - - try { - const autoSwitch = await performAutoSwitch(store, desktopLauncher, { - dryRun: false, - force: false, - signal: interruptSignal, - statusStream: streams.stderr, - statusDelayMs: managedDesktopWaitStatusDelayMs, - statusIntervalMs: managedDesktopWaitStatusIntervalMs, - timeoutMs: WATCH_AUTO_SWITCH_TIMEOUT_MS, - debugLog, - }); - - if (autoSwitch.skipped) { - currentWatchAccountLabel = autoSwitch.selected.name; - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchSkippedEvent(currentWatchAccountLabel, "already-best"), - )}\n`, - ); - } else if (autoSwitch.result) { - const previousAccountLabel = currentWatchAccountLabel; - currentWatchAccountLabel = autoSwitch.result.account.name; - streams.stdout.write( - `${formatWatchLogLine( - describeWatchAutoSwitchEvent( - previousAccountLabel, - currentWatchAccountLabel, - autoSwitch.result.warnings, - ), - )}\n`, - ); - } - - if (autoSwitch.refreshResult.failures.length > 0) { - watchExitCode = 1; - } - } finally { - switchInFlight = false; - await lock.release(); - } - }; - - let quotaReadTimer: NodeJS.Timeout | null = null; - let idleQuotaReadTimer: NodeJS.Timeout | null = null; - let quotaReadInFlight = false; - let lastQuotaReadStartedAt = 0; - let pendingQuotaReadReason: string | null = null; - let watchStopped = false; - - const clearQuotaReadTimer = () => { - if (quotaReadTimer) { - clearTimeout(quotaReadTimer); - quotaReadTimer = null; - } - }; - - const readManagedQuotaForWatch = async (reason: string) => { - if (watchStopped || interruptSignal?.aborted) { - return; - } - - if (quotaReadInFlight) { - pendingQuotaReadReason = reason; - return; - } - - quotaReadInFlight = true; - lastQuotaReadStartedAt = Date.now(); - debugLog(`watch: reading managed Desktop quota reason=${reason}`); - try { - const quota = await tryReadManagedDesktopQuota(desktopLauncher, debugLog); - if (watchStopped || interruptSignal?.aborted) { - return; - } - await handleQuotaReadResult({ - requestId: `poll:${reason}`, - quota, - shouldAutoSwitch: isTerminalWatchQuota(quota), - }); - } finally { - quotaReadInFlight = false; - const nextReason = pendingQuotaReadReason; - pendingQuotaReadReason = null; - if (nextReason && !watchStopped && !interruptSignal?.aborted) { - scheduleQuotaRead(nextReason); - } - } - }; - - function scheduleQuotaRead(reason: string): void { - if (watchStopped || interruptSignal?.aborted) { - return; - } - - pendingQuotaReadReason = reason; - if (quotaReadTimer || quotaReadInFlight) { - return; - } - - const elapsedMs = - lastQuotaReadStartedAt === 0 - ? watchQuotaMinReadIntervalMs - : Date.now() - lastQuotaReadStartedAt; - const delayMs = Math.max(0, watchQuotaMinReadIntervalMs - elapsedMs); - debugLog(`watch: scheduled quota read reason=${reason} delay_ms=${delayMs}`); - quotaReadTimer = setTimeout(() => { - quotaReadTimer = null; - const queuedReason = pendingQuotaReadReason ?? reason; - pendingQuotaReadReason = null; - void readManagedQuotaForWatch(queuedReason).catch((error) => { - watchExitCode = 1; - streams.stderr.write(`Error: ${(error as Error).message}\n`); - }); - }, delayMs); - } - - const scheduleIdleQuotaRead = () => { - if (watchStopped || interruptSignal?.aborted || watchQuotaIdleReadIntervalMs <= 0) { - return; - } - - idleQuotaReadTimer = setTimeout(() => { - idleQuotaReadTimer = null; - scheduleQuotaRead("idle"); - scheduleIdleQuotaRead(); - }, watchQuotaIdleReadIntervalMs); - }; - - try { - await readManagedQuotaForWatch("startup"); - scheduleIdleQuotaRead(); - - await desktopLauncher.watchManagedQuotaSignals({ - signal: interruptSignal, - debugLogger: debug - ? (line) => { - streams.stderr.write(`${line}\n`); - } - : undefined, - onStatus: (event) => { - streams.stderr.write( - `${formatWatchLogLine(describeWatchStatusEvent(currentWatchAccountLabel, event))}\n`, - ); - }, - onActivitySignal: (activitySignal: ManagedWatchActivitySignal) => { - debugLog( - `watch: activity signal matched reason=${activitySignal.reason} requestId=${activitySignal.requestId}`, - ); - scheduleQuotaRead(activitySignal.reason); - }, - onQuotaSignal: async (quotaSignal: ManagedQuotaSignal) => { - debugLog( - `watch: quota signal matched reason=${quotaSignal.reason} requestId=${quotaSignal.requestId}`, - ); - - const quota = await tryReadManagedDesktopQuota( - desktopLauncher, - debugLog, - quotaSignal.quota, - ); - await handleQuotaReadResult({ - requestId: quotaSignal.requestId, - quota, - shouldAutoSwitch: quotaSignal.shouldAutoSwitch, - }); - }, - }); - } finally { - watchStopped = true; - clearQuotaReadTimer(); - if (idleQuotaReadTimer) { - clearTimeout(idleQuotaReadTimer); - } - } - - return watchExitCode; + return await handleWatchCommand({ + parsed, + store, + desktopLauncher, + watchProcessManager, + streams, + interruptSignal, + debug, + debugLog, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + watchQuotaMinReadIntervalMs, + watchQuotaIdleReadIntervalMs, + }); } case "run": { @@ -1955,7 +494,7 @@ export async function runCli( const codexArgs = separatorIdx >= 0 ? process.argv.slice(separatorIdx + 1) : []; - const currentAccount = await store.getCurrentAccountInfo?.(); + const currentAccount = await readCurrentRunAccountMetadata(store); streams.stderr.write( `[codexm run] Starting codex with auto-restart on auth changes... @@ -1975,8 +514,8 @@ export async function runCli( const result = await runCodexWithAutoRestart({ codexArgs, - accountId: currentAccount?.accountId ?? null, - email: currentAccount?.email ?? null, + accountId: currentAccount.accountId, + email: currentAccount.email, debugLog, stderr: streams.stderr, }); @@ -2034,4 +573,4 @@ export async function runCli( } return 1; } -} \ No newline at end of file +} diff --git a/src/switching.ts b/src/switching.ts new file mode 100644 index 0000000..f345669 --- /dev/null +++ b/src/switching.ts @@ -0,0 +1,482 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { + AccountQuotaSummary, + AccountStore, +} from "./account-store.js"; +import type { + CodexDesktopLauncher, + RuntimeQuotaSnapshot, +} from "./codex-desktop-launch.js"; +import { + DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, +} from "./codex-desktop-launch.js"; +import { + rankAutoSwitchCandidates, + toCliQuotaSummary, + toCliQuotaSummaryFromRuntimeQuota, + type AutoSwitchCandidate, +} from "./cli/quota.js"; + +export interface AutoSwitchSelection { + refreshResult: Awaited>; + selected: AutoSwitchCandidate; + candidates: AutoSwitchCandidate[]; + quota: ReturnType | null; + warnings: string[]; +} + +export interface AutoSwitchExecutionResult { + refreshResult: { + successes: AccountQuotaSummary[]; + failures: Array<{ name: string; error: string }>; + }; + selected: AutoSwitchCandidate; + candidates: AutoSwitchCandidate[]; + quota: ReturnType | null; + skipped: boolean; + result: Awaited> | null; + warnings: string[]; +} + +export interface SwitchLockOwner { + pid: number; + command: string; + started_at: string; +} + +const SWITCH_LOCKS_DIR_NAME = "locks"; +const SWITCH_LOCK_DIR_NAME = "switch.lock"; +const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_DELAY_MS = 1_000; +const DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_INTERVAL_MS = 5_000; + +export const NON_MANAGED_DESKTOP_WARNING_PREFIX = + '"codexm switch" updates local auth, but running Codex Desktop may still use the previous login state.'; +export const NON_MANAGED_DESKTOP_FOLLOWUP_WARNING = + 'Use "codexm launch" to start Codex Desktop with the selected auth; future switches can apply immediately to that session.'; + +export function stripManagedDesktopWarning(warnings: string[]): string[] { + return warnings.filter( + (warning) => + warning !== NON_MANAGED_DESKTOP_WARNING_PREFIX && + warning !== NON_MANAGED_DESKTOP_FOLLOWUP_WARNING, + ); +} + +function startManagedDesktopWaitReporter( + stream: NodeJS.WriteStream, + options: { + delayMs?: number; + intervalMs?: number; + } = {}, +): { + stop: (result: "success" | "cancelled") => void; +} { + const delayMs = options.delayMs ?? DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_DELAY_MS; + const intervalMs = options.intervalMs ?? DEFAULT_MANAGED_DESKTOP_WAIT_STATUS_INTERVAL_MS; + const startedAt = Date.now(); + let started = false; + let intervalHandle: NodeJS.Timeout | null = null; + + const timeoutHandle = setTimeout(() => { + started = true; + stream.write( + "Waiting for the current Codex Desktop thread to finish before applying the switch...\n", + ); + + intervalHandle = setInterval(() => { + const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000)); + stream.write( + `Still waiting for the current Codex Desktop thread to finish (${elapsedSeconds}s elapsed)...\n`, + ); + }, intervalMs); + intervalHandle.unref?.(); + }, delayMs); + timeoutHandle.unref?.(); + + return { + stop: (result) => { + clearTimeout(timeoutHandle); + if (intervalHandle) { + clearInterval(intervalHandle); + } + + if (started && result === "success") { + stream.write("Applied the switch to the managed Codex Desktop session.\n"); + } + }, + }; +} + +export async function refreshManagedDesktopAfterSwitch( + warnings: string[], + desktopLauncher: CodexDesktopLauncher, + options: { + force?: boolean; + signal?: AbortSignal; + statusStream?: NodeJS.WriteStream; + statusDelayMs?: number; + statusIntervalMs?: number; + timeoutMs?: number; + } = {}, +): Promise<"applied" | "killed" | "none" | "other-running" | "failed"> { + let reporter: ReturnType | null = null; + if (options.force !== true && options.statusStream) { + try { + if (await desktopLauncher.isManagedDesktopRunning()) { + reporter = startManagedDesktopWaitReporter(options.statusStream, { + delayMs: options.statusDelayMs, + intervalMs: options.statusIntervalMs, + }); + } + } catch { + // Keep status reporting best-effort, same as the rest of Desktop inspection. + } + } + + try { + if ( + await desktopLauncher.applyManagedSwitch({ + force: options.force === true, + signal: options.signal, + timeoutMs: options.timeoutMs ?? DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, + }) + ) { + reporter?.stop("success"); + return "applied"; + } + } catch (error) { + reporter?.stop("cancelled"); + if ((error as Error).name === "AbortError") { + warnings.push( + "Refreshing the running codexm-managed Codex Desktop session was interrupted after the local auth switched. Relaunch Codex Desktop or rerun switch --force to apply the change immediately.", + ); + return "failed"; + } + + if (options.force === true) { + try { + await desktopLauncher.quitRunningApps({ force: true }); + warnings.push( + `Force-killed the running codexm-managed Codex Desktop session because the immediate refresh path failed: ${(error as Error).message} Relaunch Codex Desktop to continue with the new auth.`, + ); + return "killed"; + } catch (fallbackError) { + warnings.push( + `Failed to refresh the running codexm-managed Codex Desktop session: ${(error as Error).message} Fallback force-kill also failed: ${(fallbackError as Error).message}`, + ); + return "failed"; + } + } + + warnings.push( + `Failed to refresh the running codexm-managed Codex Desktop session: ${(error as Error).message}`, + ); + return "failed"; + } + + reporter?.stop("cancelled"); + + try { + const runningApps = await desktopLauncher.listRunningApps(); + if (runningApps.length === 0) { + return "none"; + } + + if (runningApps.length > 0) { + warnings.push(NON_MANAGED_DESKTOP_WARNING_PREFIX); + warnings.push(NON_MANAGED_DESKTOP_FOLLOWUP_WARNING); + return "other-running"; + } + } catch { + // Keep Desktop detection best-effort so switch success does not depend on local process inspection. + } + + return "failed"; +} + +export async function resolveManagedAccountByName( + store: AccountStore, + name: string, +): Promise>["accounts"][number] | null> { + const { accounts } = await store.listAccounts(); + return accounts.find((account) => account.name === name) ?? null; +} + +export async function tryReadManagedDesktopQuota( + desktopLauncher: CodexDesktopLauncher, + debugLog?: (message: string) => void, + fallbackQuota?: RuntimeQuotaSnapshot | null, +): Promise | null> { + if (fallbackQuota) { + debugLog?.("watch: using quota carried by Desktop bridge signal"); + return toCliQuotaSummaryFromRuntimeQuota(fallbackQuota); + } + + try { + const quota = await desktopLauncher.readManagedCurrentQuota(); + if (!quota) { + debugLog?.("watch: managed Desktop quota unavailable"); + return null; + } + + debugLog?.("watch: using managed Desktop quota"); + return toCliQuotaSummaryFromRuntimeQuota(quota); + } catch (error) { + debugLog?.(`watch: managed Desktop quota read failed: ${(error as Error).message}`); + return null; + } +} + +export async function selectAutoSwitchAccount(store: AccountStore): Promise { + const refreshResult = await store.refreshAllQuotas(); + const candidates = rankAutoSwitchCandidates(refreshResult.successes); + if (candidates.length === 0) { + throw new Error("No auto-switch candidate has usable 5H or 1W quota data available."); + } + + const selected = candidates[0]; + const selectedQuota = + refreshResult.successes.find((account) => account.name === selected.name) ?? null; + const quota = selectedQuota ? toCliQuotaSummary(selectedQuota) : null; + const warnings = refreshResult.failures.map((failure) => `${failure.name}: ${failure.error}`); + + return { + refreshResult, + selected, + candidates, + quota, + warnings, + }; +} + +export async function performAutoSwitch( + store: AccountStore, + desktopLauncher: CodexDesktopLauncher, + selectionOrOptions: + | AutoSwitchSelection + | { + dryRun: boolean; + force: boolean; + signal?: AbortSignal; + statusStream?: NodeJS.WriteStream; + statusDelayMs?: number; + statusIntervalMs?: number; + timeoutMs?: number; + debugLog?: (message: string) => void; + }, + maybeOptions?: { + dryRun: boolean; + force: boolean; + signal?: AbortSignal; + statusStream?: NodeJS.WriteStream; + statusDelayMs?: number; + statusIntervalMs?: number; + timeoutMs?: number; + debugLog?: (message: string) => void; + }, +): Promise { + const selection = maybeOptions + ? selectionOrOptions as AutoSwitchSelection + : await selectAutoSwitchAccount(store); + const options = (maybeOptions ?? selectionOrOptions) as { + dryRun: boolean; + force: boolean; + signal?: AbortSignal; + statusStream?: NodeJS.WriteStream; + statusDelayMs?: number; + statusIntervalMs?: number; + timeoutMs?: number; + debugLog?: (message: string) => void; + }; + + options.debugLog?.(`switch: mode=auto dry_run=${options.dryRun} force=${options.force}`); + const { refreshResult, selected, candidates, quota, warnings } = selection; + if (options.dryRun) { + options.debugLog?.( + `switch: auto-selected target=${selected.name} candidates=${candidates.length} warnings=${warnings.length} dry_run=true`, + ); + return { + refreshResult, + selected, + candidates, + quota, + skipped: false, + result: null, + warnings, + }; + } + + const currentStatus = await store.getCurrentStatus(); + if ( + selected.available === "available" && + currentStatus.matched_accounts.includes(selected.name) + ) { + options.debugLog?.( + `switch: auto-selected target=${selected.name} candidates=${candidates.length} skipped=already_current_best`, + ); + return { + refreshResult, + selected, + candidates, + quota, + skipped: true, + result: null, + warnings, + }; + } + + const result = await store.switchAccount(selected.name); + for (const warning of warnings) { + result.warnings.push(warning); + } + result.warnings = stripManagedDesktopWarning(result.warnings); + + await refreshManagedDesktopAfterSwitch(result.warnings, desktopLauncher, { + force: options.force, + signal: options.signal, + statusStream: options.statusStream, + statusDelayMs: options.statusDelayMs, + statusIntervalMs: options.statusIntervalMs, + timeoutMs: options.timeoutMs, + }); + options.debugLog?.( + `switch: completed mode=auto target=${result.account.name} candidates=${candidates.length} warnings=${result.warnings.length}`, + ); + + return { + refreshResult, + selected, + candidates, + quota, + skipped: false, + result, + warnings: result.warnings, + }; +} + +function getSwitchLockDir(store: AccountStore): string { + return join(store.paths.codexTeamDir, SWITCH_LOCKS_DIR_NAME, SWITCH_LOCK_DIR_NAME); +} + +function getSwitchLockOwnerPath(store: AccountStore): string { + return join(getSwitchLockDir(store), "owner.json"); +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ESRCH") { + return false; + } + return true; + } +} + +async function readSwitchLockOwner(store: AccountStore): Promise { + try { + const raw = await readFile(getSwitchLockOwnerPath(store), "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.pid === "number" && + typeof parsed.command === "string" && + typeof parsed.started_at === "string" + ) { + return { + pid: parsed.pid, + command: parsed.command, + started_at: parsed.started_at, + }; + } + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "ENOENT") { + return null; + } + } + + return null; +} + +export async function tryAcquireSwitchLock( + store: AccountStore, + command: string, +): Promise< + | { acquired: true; lockPath: string; release: () => Promise } + | { acquired: false; lockPath: string; owner: SwitchLockOwner | null } +> { + const locksDir = join(store.paths.codexTeamDir, SWITCH_LOCKS_DIR_NAME); + const lockPath = getSwitchLockDir(store); + const ownerPath = getSwitchLockOwnerPath(store); + await mkdir(locksDir, { recursive: true, mode: 0o700 }); + + const tryCreateLock = async (): Promise => { + try { + await mkdir(lockPath, { mode: 0o700 }); + return true; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "EEXIST") { + return false; + } + throw error; + } + }; + + let created = await tryCreateLock(); + if (!created) { + const existingOwner = await readSwitchLockOwner(store); + if (!existingOwner || !isProcessAlive(existingOwner.pid)) { + await rm(lockPath, { recursive: true, force: true }); + created = await tryCreateLock(); + } + } + + if (!created) { + return { + acquired: false, + lockPath, + owner: await readSwitchLockOwner(store), + }; + } + + const owner: SwitchLockOwner = { + pid: process.pid, + command, + started_at: new Date().toISOString(), + }; + + try { + await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + } catch (error) { + await rm(lockPath, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + + return { + acquired: true, + lockPath, + release: async () => { + await rm(lockPath, { recursive: true, force: true }); + }, + }; +} + +export function describeBusySwitchLock(lockPath: string, owner: SwitchLockOwner | null): string { + let message = `Another codexm switch or launch operation is already in progress. Lock: ${lockPath}`; + if (owner) { + message += ` (pid ${owner.pid}, command ${JSON.stringify(owner.command)}, started ${owner.started_at})`; + } + return message; +} diff --git a/src/watch-detached.ts b/src/watch-detached.ts new file mode 100644 index 0000000..d233e74 --- /dev/null +++ b/src/watch-detached.ts @@ -0,0 +1,33 @@ +import type { WatchProcessManager, WatchProcessState } from "./watch-process.js"; + +export async function ensureDetachedWatch( + watchProcessManager: Pick, + options: { autoSwitch: boolean; debug: boolean }, +): Promise< + | { action: "started" | "restarted"; state: WatchProcessState } + | { action: "reused"; state: WatchProcessState } +> { + const status = await watchProcessManager.getStatus(); + if (status.running && status.state) { + if ( + status.state.auto_switch === options.autoSwitch && + status.state.debug === options.debug + ) { + return { + action: "reused", + state: status.state, + }; + } + + await watchProcessManager.stop(); + return { + action: "restarted", + state: await watchProcessManager.startDetached(options), + }; + } + + return { + action: "started", + state: await watchProcessManager.startDetached(options), + }; +} diff --git a/src/watch-output.ts b/src/watch-output.ts new file mode 100644 index 0000000..59088b9 --- /dev/null +++ b/src/watch-output.ts @@ -0,0 +1,117 @@ +import dayjs from "dayjs"; + +import type { AccountStore } from "./account-store.js"; +import type { CliQuotaSummary } from "./cli/quota.js"; +import type { ManagedWatchStatusEvent } from "./codex-desktop-launch.js"; + +function formatWatchField(key: string, value: string | number): string { + if (typeof value === "number") { + return `${key}=${value}`; + } + + return `${key}=${JSON.stringify(value)}`; +} + +function computeRemainingPercent(usedPercent: number | undefined): number | null { + if (typeof usedPercent !== "number") { + return null; + } + + return Math.max(0, 100 - usedPercent); +} + +export function formatWatchLogLine(message: string): string { + return `[${dayjs().format("HH:mm:ss")}] ${message}`; +} + +export function describeWatchQuotaUpdate(quota: CliQuotaSummary | null): string { + if (!quota) { + return "Quota update: Usage: unavailable"; + } + + if (quota.refresh_status !== "ok") { + if (quota.refresh_status === "unsupported") { + return "Quota update: Usage: unsupported"; + } + + return `Quota update: Usage: ${quota.refresh_status}${quota.error_message ? ` | ${quota.error_message}` : ""}`; + } + + return `Quota update: Usage: ${quota.available ?? "unknown"} | 5H ${quota.five_hour?.used_percent ?? "-"}% used | 1W ${quota.one_week?.used_percent ?? "-"}% used`; +} + +export function describeWatchQuotaEvent( + accountLabel: string, + quota: CliQuotaSummary | null, +): string { + if (!quota || quota.refresh_status !== "ok") { + return `quota ${formatWatchField("account", accountLabel)} status=${ + quota?.refresh_status ?? "unavailable" + }`; + } + + return [ + "quota", + formatWatchField("account", accountLabel), + `usage=${quota.available ?? "unknown"}`, + `5H=${computeRemainingPercent(quota.five_hour?.used_percent) ?? "-"}% left`, + `1W=${computeRemainingPercent(quota.one_week?.used_percent) ?? "-"}% left`, + ].join(" "); +} + +export function describeWatchStatusEvent(accountLabel: string, event: ManagedWatchStatusEvent): string { + if (event.type === "reconnected") { + return [ + "reconnect-ok", + formatWatchField("account", accountLabel), + formatWatchField("attempt", event.attempt), + ].join(" "); + } + + const fields = [ + "reconnect-lost", + formatWatchField("account", accountLabel), + formatWatchField("attempt", event.attempt), + ]; + if (event.error) { + fields.push(formatWatchField("error", event.error)); + } + return fields.join(" "); +} + +export function describeWatchAutoSwitchEvent( + fromAccount: string, + toAccount: string, + warnings: string[], +): string { + const fields = [ + "auto-switch", + formatWatchField("from", fromAccount), + formatWatchField("to", toAccount), + ]; + if (warnings.length > 0) { + fields.push(formatWatchField("warnings", warnings.length)); + } + return fields.join(" "); +} + +export function describeWatchAutoSwitchSkippedEvent(accountLabel: string, reason: string): string { + return [ + "auto-switch-skipped", + formatWatchField("account", accountLabel), + `reason=${reason}`, + ].join(" "); +} + +export async function resolveWatchAccountLabel(store: AccountStore): Promise { + try { + const current = await store.getCurrentStatus(); + if (current.matched_accounts.length === 1) { + return current.matched_accounts[0]; + } + } catch { + // Keep watch logging best-effort when local current-state inspection fails. + } + + return "current"; +} diff --git a/src/watch-session.ts b/src/watch-session.ts new file mode 100644 index 0000000..b1a45d4 --- /dev/null +++ b/src/watch-session.ts @@ -0,0 +1,516 @@ +import type { AccountStore } from "./account-store.js"; +import type { + CodexDesktopLauncher, + ManagedQuotaSignal, + ManagedWatchActivitySignal, +} from "./codex-desktop-launch.js"; +import { createCliProcessManager } from "./codex-cli-watcher.js"; +import { + isTerminalWatchQuota, + toCliQuotaSummaryFromRuntimeQuota, +} from "./cli/quota.js"; +import { + appendWatchQuotaHistory, + createWatchHistoryStore, +} from "./watch-history.js"; +import { + describeBusySwitchLock, + performAutoSwitch, + tryAcquireSwitchLock, + tryReadManagedDesktopQuota, +} from "./switching.js"; +import { + describeWatchAutoSwitchEvent, + describeWatchAutoSwitchSkippedEvent, + describeWatchQuotaEvent, + describeWatchStatusEvent, + formatWatchLogLine, + resolveWatchAccountLabel, +} from "./watch-output.js"; + +const WATCH_AUTO_SWITCH_TIMEOUT_MS = 600_000; + +interface CliStreams { + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; +} + +export async function runCliWatchSession(options: { + store: AccountStore; + desktopLauncher: CodexDesktopLauncher; + streams: CliStreams; + interruptSignal?: AbortSignal; + autoSwitch: boolean; + debug: boolean; + debugLog: (message: string) => void; + watchQuotaMinReadIntervalMs: number; + managedDesktopWaitStatusDelayMs: number; + managedDesktopWaitStatusIntervalMs: number; +}): Promise { + const { + store, + desktopLauncher, + streams, + interruptSignal, + autoSwitch, + debug, + debugLog, + watchQuotaMinReadIntervalMs, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + } = options; + + const platformModule = await import("./platform.js"); + const platform = await platformModule.getPlatform(); + debugLog(`watch: no managed Desktop detected, entering CLI watch mode (platform=${platform})`); + streams.stderr.write( + `${formatWatchLogLine("No managed Codex Desktop session — entering CLI watch mode")}\n`, + ); + + const cliManager = createCliProcessManager({ + pollIntervalMs: watchQuotaMinReadIntervalMs, + }); + + const discovered = await cliManager.findRunningCliProcesses(); + if (discovered.length > 0) { + streams.stderr.write( + `${formatWatchLogLine(`Found ${discovered.length} running codex CLI process(es)`)}\n`, + ); + for (const proc of discovered) { + debugLog(`watch: discovered CLI process pid=${proc.pid} command=${proc.command}`); + } + } + + let cliWatchExitCode = 0; + let cliSwitchInFlight = false; + let cliLastSwitchStartedAt = 0; + let cliLastQuotaUpdateLine: string | null = null; + let cliCurrentAccountLabel = await resolveWatchAccountLabel(store); + const cliWatchSwitchCooldownMs = 5_000; + + const handleCliQuotaResult = async (quotaSignal: { + requestId: string; + quota: ReturnType | null; + shouldAutoSwitch: boolean; + }) => { + const quotaUpdateLine = describeWatchQuotaEvent(cliCurrentAccountLabel, quotaSignal.quota); + if (quotaUpdateLine !== cliLastQuotaUpdateLine) { + streams.stdout.write(`${formatWatchLogLine(quotaUpdateLine)}\n`); + cliLastQuotaUpdateLine = quotaUpdateLine; + } + + if (!autoSwitch || !quotaSignal.shouldAutoSwitch) { + return; + } + + const lock = await tryAcquireSwitchLock(store, "watch-cli"); + if (!lock.acquired) { + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchSkippedEvent(cliCurrentAccountLabel, "lock-busy"), + )}\n`, + ); + return; + } + + const now = Date.now(); + if (cliSwitchInFlight || now - cliLastSwitchStartedAt < cliWatchSwitchCooldownMs) { + await lock.release(); + return; + } + + cliSwitchInFlight = true; + cliLastSwitchStartedAt = now; + + try { + const switchResult = await performAutoSwitch(store, desktopLauncher, { + dryRun: false, + force: false, + signal: interruptSignal, + statusStream: streams.stderr, + statusDelayMs: managedDesktopWaitStatusDelayMs, + statusIntervalMs: managedDesktopWaitStatusIntervalMs, + timeoutMs: WATCH_AUTO_SWITCH_TIMEOUT_MS, + debugLog, + }); + + if (switchResult.skipped) { + cliCurrentAccountLabel = switchResult.selected.name; + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchSkippedEvent(cliCurrentAccountLabel, "already-best"), + )}\n`, + ); + } else if (switchResult.result) { + const previousLabel = cliCurrentAccountLabel; + cliCurrentAccountLabel = switchResult.result.account.name; + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchEvent( + previousLabel, + cliCurrentAccountLabel, + switchResult.result.warnings, + ), + )}\n`, + ); + + const restartResult = await cliManager.restartCliProcess({ + accountId: switchResult.selected.account_id ?? undefined, + signal: interruptSignal, + }); + if (restartResult.restarted > 0) { + streams.stderr.write( + `${formatWatchLogLine( + `Restarted ${restartResult.restarted} CLI process(es). Use "codexm run" for seamless auto-restart.`, + )}\n`, + ); + } + if (restartResult.failed > 0) { + streams.stderr.write( + `${formatWatchLogLine( + `Failed to restart ${restartResult.failed} CLI process(es)`, + )}\n`, + ); + } + } + + if (switchResult.refreshResult.failures.length > 0) { + cliWatchExitCode = 1; + } + } finally { + cliSwitchInFlight = false; + await lock.release(); + } + }; + + try { + await cliManager.watchCliQuotaSignals({ + pollIntervalMs: watchQuotaMinReadIntervalMs, + signal: interruptSignal, + debugLogger: debug + ? (line) => { + streams.stderr.write(`${line}\n`); + } + : undefined, + onStatus: async (event) => { + if (event.type === "disconnected") { + streams.stderr.write( + `${formatWatchLogLine(`CLI connection lost (attempt ${event.attempt}): ${event.error ?? "unknown"}`)}\n`, + ); + } else if (event.type === "reconnected") { + streams.stderr.write( + `${formatWatchLogLine("CLI connection established")}\n`, + ); + } + }, + onQuotaSignal: async (quotaSignal) => { + const quota = quotaSignal.quota + ? toCliQuotaSummaryFromRuntimeQuota(quotaSignal.quota) + : null; + await handleCliQuotaResult({ + requestId: quotaSignal.requestId, + quota, + shouldAutoSwitch: quotaSignal.shouldAutoSwitch, + }); + }, + }); + } catch (error) { + if (!interruptSignal?.aborted) { + streams.stderr.write(`Error: ${(error as Error).message}\n`); + cliWatchExitCode = 1; + } + } + + return cliWatchExitCode; +} + +export async function runManagedDesktopWatchSession(options: { + store: AccountStore; + desktopLauncher: CodexDesktopLauncher; + streams: CliStreams; + interruptSignal?: AbortSignal; + autoSwitch: boolean; + debug: boolean; + debugLog: (message: string) => void; + managedDesktopWaitStatusDelayMs: number; + managedDesktopWaitStatusIntervalMs: number; + watchQuotaMinReadIntervalMs: number; + watchQuotaIdleReadIntervalMs: number; +}): Promise { + const { + store, + desktopLauncher, + streams, + interruptSignal, + autoSwitch, + debug, + debugLog, + managedDesktopWaitStatusDelayMs, + managedDesktopWaitStatusIntervalMs, + watchQuotaMinReadIntervalMs, + watchQuotaIdleReadIntervalMs, + } = options; + + let watchExitCode = 0; + let switchInFlight = false; + let lastSwitchStartedAt = 0; + let lastQuotaUpdateLine: string | null = null; + let currentWatchAccountLabel = await resolveWatchAccountLabel(store); + const watchHistoryStore = createWatchHistoryStore(store.paths.codexTeamDir); + const watchSwitchCooldownMs = 5_000; + + debugLog("watch: starting managed desktop quota watch"); + debugLog(`watch: auto-switch ${autoSwitch ? "enabled" : "disabled"}`); + + const handleQuotaReadResult = async (quotaSignal: { + requestId: string; + quota: ReturnType | null; + shouldAutoSwitch: boolean; + }) => { + const quota = quotaSignal.quota; + if (quota?.refresh_status === "ok") { + try { + await appendWatchQuotaHistory(watchHistoryStore, { + recordedAt: quota.fetched_at ?? new Date().toISOString(), + accountName: currentWatchAccountLabel, + accountId: quota.account_id, + identity: quota.identity, + planType: quota.plan_type, + available: quota.available, + fiveHour: quota.five_hour + ? { + usedPercent: quota.five_hour.used_percent, + windowSeconds: quota.five_hour.window_seconds, + resetAt: quota.five_hour.reset_at ?? null, + } + : null, + oneWeek: quota.one_week + ? { + usedPercent: quota.one_week.used_percent, + windowSeconds: quota.one_week.window_seconds, + resetAt: quota.one_week.reset_at ?? null, + } + : null, + }); + } catch (error) { + debugLog(`watch: failed to persist watch history: ${(error as Error).message}`); + } + } + const quotaUpdateLine = describeWatchQuotaEvent(currentWatchAccountLabel, quota); + if (quotaUpdateLine !== lastQuotaUpdateLine) { + streams.stdout.write(`${formatWatchLogLine(quotaUpdateLine)}\n`); + lastQuotaUpdateLine = quotaUpdateLine; + } else { + debugLog(`watch: quota output unchanged for requestId=${quotaSignal.requestId}`); + } + if (!autoSwitch) { + return; + } + + if (!quotaSignal.shouldAutoSwitch) { + debugLog( + `watch: skipping auto switch for requestId=${quotaSignal.requestId} because the event is informational only`, + ); + return; + } + + const lock = await tryAcquireSwitchLock(store, "watch"); + if (!lock.acquired) { + debugLog(`watch: switch lock is busy at ${lock.lockPath}`); + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchSkippedEvent(currentWatchAccountLabel, "lock-busy"), + )}\n`, + ); + return; + } + + const now = Date.now(); + if (switchInFlight || now - lastSwitchStartedAt < watchSwitchCooldownMs) { + await lock.release(); + debugLog( + `watch: skipped auto switch for requestId=${quotaSignal.requestId} because another switch is already in progress`, + ); + return; + } + + switchInFlight = true; + lastSwitchStartedAt = now; + + try { + const autoSwitchResult = await performAutoSwitch(store, desktopLauncher, { + dryRun: false, + force: false, + signal: interruptSignal, + statusStream: streams.stderr, + statusDelayMs: managedDesktopWaitStatusDelayMs, + statusIntervalMs: managedDesktopWaitStatusIntervalMs, + timeoutMs: WATCH_AUTO_SWITCH_TIMEOUT_MS, + debugLog, + }); + + if (autoSwitchResult.skipped) { + currentWatchAccountLabel = autoSwitchResult.selected.name; + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchSkippedEvent(currentWatchAccountLabel, "already-best"), + )}\n`, + ); + } else if (autoSwitchResult.result) { + const previousAccountLabel = currentWatchAccountLabel; + currentWatchAccountLabel = autoSwitchResult.result.account.name; + streams.stdout.write( + `${formatWatchLogLine( + describeWatchAutoSwitchEvent( + previousAccountLabel, + currentWatchAccountLabel, + autoSwitchResult.result.warnings, + ), + )}\n`, + ); + } + + if (autoSwitchResult.refreshResult.failures.length > 0) { + watchExitCode = 1; + } + } finally { + switchInFlight = false; + await lock.release(); + } + }; + + let quotaReadTimer: NodeJS.Timeout | null = null; + let idleQuotaReadTimer: NodeJS.Timeout | null = null; + let quotaReadInFlight = false; + let lastQuotaReadStartedAt = 0; + let pendingQuotaReadReason: string | null = null; + let watchStopped = false; + + const clearQuotaReadTimer = () => { + if (quotaReadTimer) { + clearTimeout(quotaReadTimer); + quotaReadTimer = null; + } + }; + + const readManagedQuotaForWatch = async (reason: string) => { + if (watchStopped || interruptSignal?.aborted) { + return; + } + + if (quotaReadInFlight) { + pendingQuotaReadReason = reason; + return; + } + + quotaReadInFlight = true; + lastQuotaReadStartedAt = Date.now(); + debugLog(`watch: reading managed Desktop quota reason=${reason}`); + try { + const quota = await tryReadManagedDesktopQuota(desktopLauncher, debugLog); + if (watchStopped || interruptSignal?.aborted) { + return; + } + await handleQuotaReadResult({ + requestId: `poll:${reason}`, + quota, + shouldAutoSwitch: isTerminalWatchQuota(quota), + }); + } finally { + quotaReadInFlight = false; + const nextReason = pendingQuotaReadReason; + pendingQuotaReadReason = null; + if (nextReason && !watchStopped && !interruptSignal?.aborted) { + scheduleQuotaRead(nextReason); + } + } + }; + + const scheduleQuotaRead = (reason: string): void => { + if (watchStopped || interruptSignal?.aborted) { + return; + } + + pendingQuotaReadReason = reason; + if (quotaReadTimer || quotaReadInFlight) { + return; + } + + const elapsedMs = + lastQuotaReadStartedAt === 0 + ? watchQuotaMinReadIntervalMs + : Date.now() - lastQuotaReadStartedAt; + const delayMs = Math.max(0, watchQuotaMinReadIntervalMs - elapsedMs); + debugLog(`watch: scheduled quota read reason=${reason} delay_ms=${delayMs}`); + quotaReadTimer = setTimeout(() => { + quotaReadTimer = null; + const queuedReason = pendingQuotaReadReason ?? reason; + pendingQuotaReadReason = null; + void readManagedQuotaForWatch(queuedReason).catch((error) => { + watchExitCode = 1; + streams.stderr.write(`Error: ${(error as Error).message}\n`); + }); + }, delayMs); + }; + + const scheduleIdleQuotaRead = () => { + if (watchStopped || interruptSignal?.aborted || watchQuotaIdleReadIntervalMs <= 0) { + return; + } + + idleQuotaReadTimer = setTimeout(() => { + idleQuotaReadTimer = null; + scheduleQuotaRead("idle"); + scheduleIdleQuotaRead(); + }, watchQuotaIdleReadIntervalMs); + }; + + try { + await readManagedQuotaForWatch("startup"); + scheduleIdleQuotaRead(); + + await desktopLauncher.watchManagedQuotaSignals({ + signal: interruptSignal, + debugLogger: debug + ? (line) => { + streams.stderr.write(`${line}\n`); + } + : undefined, + onStatus: (event) => { + streams.stderr.write( + `${formatWatchLogLine(describeWatchStatusEvent(currentWatchAccountLabel, event))}\n`, + ); + }, + onActivitySignal: (activitySignal: ManagedWatchActivitySignal) => { + debugLog( + `watch: activity signal matched reason=${activitySignal.reason} requestId=${activitySignal.requestId}`, + ); + scheduleQuotaRead(activitySignal.reason); + }, + onQuotaSignal: async (quotaSignal: ManagedQuotaSignal) => { + debugLog( + `watch: quota signal matched reason=${quotaSignal.reason} requestId=${quotaSignal.requestId}`, + ); + + const quota = await tryReadManagedDesktopQuota( + desktopLauncher, + debugLog, + quotaSignal.quota, + ); + await handleQuotaReadResult({ + requestId: quotaSignal.requestId, + quota, + shouldAutoSwitch: quotaSignal.shouldAutoSwitch, + }); + }, + }); + } finally { + watchStopped = true; + clearQuotaReadTimer(); + if (idleQuotaReadTimer) { + clearTimeout(idleQuotaReadTimer); + } + } + + return watchExitCode; +} diff --git a/tests/desktop-managed-state.test.ts b/tests/desktop-managed-state.test.ts new file mode 100644 index 0000000..236f29e --- /dev/null +++ b/tests/desktop-managed-state.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "@rstest/core"; + +import { + isOnlyManagedDesktopInstanceRunning, + isRunningDesktopFromApp, +} from "../src/desktop-managed-state.js"; + +describe("desktop-managed-state", () => { + test("matches the managed macOS Desktop binary path", () => { + expect( + isRunningDesktopFromApp( + { + pid: 123, + command: "/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=39223", + }, + "/Applications/Codex.app", + "darwin", + ), + ).toBe(true); + }); + + test("treats non-macOS Desktop commands via platform-aware detection", () => { + expect( + isRunningDesktopFromApp( + { + pid: 456, + command: "/usr/local/bin/codex --remote-debugging-port=39223", + }, + "/unused", + "linux", + ), + ).toBe(true); + }); + + test("recognizes when only the managed Desktop instance is running", () => { + expect( + isOnlyManagedDesktopInstanceRunning( + [ + { + pid: 321, + command: "/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=39223", + }, + ], + { + pid: 321, + app_path: "/Applications/Codex.app", + remote_debugging_port: 39223, + managed_by_codexm: true, + started_at: "2026-04-13T00:00:00.000Z", + }, + "darwin", + ), + ).toBe(true); + }); +}); diff --git a/tests/watch-detached.test.ts b/tests/watch-detached.test.ts new file mode 100644 index 0000000..4064785 --- /dev/null +++ b/tests/watch-detached.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "@rstest/core"; + +import { ensureDetachedWatch } from "../src/watch-detached.js"; + +describe("watch-detached", () => { + test("reuses an existing detached watch when options match", async () => { + const result = await ensureDetachedWatch( + { + async getStatus() { + return { + running: true, + state: { + pid: 11, + started_at: "2026-04-13T00:00:00.000Z", + log_path: "/tmp/watch.log", + auto_switch: true, + debug: false, + }, + }; + }, + async startDetached() { + throw new Error("should not restart"); + }, + async stop() { + throw new Error("should not stop"); + }, + }, + { autoSwitch: true, debug: false }, + ); + + expect(result).toEqual({ + action: "reused", + state: { + pid: 11, + started_at: "2026-04-13T00:00:00.000Z", + log_path: "/tmp/watch.log", + auto_switch: true, + debug: false, + }, + }); + }); + + test("restarts an existing detached watch when options differ", async () => { + const calls: string[] = []; + + const result = await ensureDetachedWatch( + { + async getStatus() { + return { + running: true, + state: { + pid: 11, + started_at: "2026-04-13T00:00:00.000Z", + log_path: "/tmp/watch.log", + auto_switch: false, + debug: false, + }, + }; + }, + async startDetached() { + calls.push("start"); + return { + pid: 22, + started_at: "2026-04-13T00:01:00.000Z", + log_path: "/tmp/watch.log", + auto_switch: true, + debug: false, + }; + }, + async stop() { + calls.push("stop"); + return { + running: false, + state: null, + stopped: true, + }; + }, + }, + { autoSwitch: true, debug: false }, + ); + + expect(calls).toEqual(["stop", "start"]); + expect(result).toEqual({ + action: "restarted", + state: { + pid: 22, + started_at: "2026-04-13T00:01:00.000Z", + log_path: "/tmp/watch.log", + auto_switch: true, + debug: false, + }, + }); + }); +}); diff --git a/tests/watch-output.test.ts b/tests/watch-output.test.ts new file mode 100644 index 0000000..3e7921b --- /dev/null +++ b/tests/watch-output.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "@rstest/core"; + +import { + describeWatchAutoSwitchEvent, + describeWatchAutoSwitchSkippedEvent, + describeWatchQuotaEvent, + describeWatchStatusEvent, +} from "../src/watch-output.js"; + +describe("watch-output", () => { + test("renders structured quota lines for usable quota", () => { + expect( + describeWatchQuotaEvent("alpha", { + name: "alpha", + account_id: "acct-1", + user_id: null, + identity: "id-1", + plan_type: "plus", + available: "available", + refresh_status: "ok", + fetched_at: "2026-04-13T00:00:00.000Z", + credits_balance: 1, + unlimited: false, + error_message: null, + five_hour: { + used_percent: 20, + window_seconds: 18_000, + reset_at: "2026-04-13T05:00:00.000Z", + }, + one_week: { + used_percent: 10, + window_seconds: 604_800, + reset_at: "2026-04-20T00:00:00.000Z", + }, + }), + ).toBe('quota account="alpha" usage=available 5H=80% left 1W=90% left'); + }); + + test("renders unavailable quota lines without percent fields", () => { + expect(describeWatchQuotaEvent("alpha", null)).toBe('quota account="alpha" status=unavailable'); + }); + + test("renders reconnect and auto-switch events", () => { + expect( + describeWatchStatusEvent("alpha", { + type: "disconnected", + attempt: 2, + error: "socket closed", + }), + ).toBe('reconnect-lost account="alpha" attempt=2 error="socket closed"'); + + expect(describeWatchAutoSwitchEvent("alpha", "beta", ["warn"])).toBe( + 'auto-switch from="alpha" to="beta" warnings=1', + ); + + expect(describeWatchAutoSwitchSkippedEvent("beta", "lock-busy")).toBe( + 'auto-switch-skipped account="beta" reason=lock-busy', + ); + }); +}); From 0d7b898909546b9b21d819105cbd2717076b1291 Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 19:01:17 +0800 Subject: [PATCH 2/6] test(cli): make runner and watcher deterministic --- src/codex-cli-runner.ts | 45 +++- src/codex-cli-watcher.ts | 34 ++- tests/codex-cli-runner.test.ts | 462 +++++++++----------------------- tests/codex-cli-watcher.test.ts | 2 +- 4 files changed, 185 insertions(+), 358 deletions(-) diff --git a/src/codex-cli-runner.ts b/src/codex-cli-runner.ts index 4edbf37..ed2aae6 100644 --- a/src/codex-cli-runner.ts +++ b/src/codex-cli-runner.ts @@ -33,7 +33,7 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { readFile, stat } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { watch, type FSWatcher } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -71,6 +71,14 @@ export interface RunnerOptions { disableAuthWatch?: boolean; /** CLI process manager instance (for DI/testing). */ cliManager?: CliProcessManager; + /** Spawn implementation override for tests. */ + spawnImpl?: typeof spawn; + /** File watch implementation override for tests. */ + watchImpl?: typeof watch; + /** File read implementation override for tests. */ + readFileImpl?: typeof readFile; + /** Attach process-level SIGINT/SIGTERM handlers. Default: true. */ + attachProcessSignalHandlers?: boolean; } export interface RunnerResult { @@ -86,9 +94,12 @@ function hashFileContent(content: string): string { return createHash("sha256").update(content).digest("hex"); } -async function readAuthHash(authFilePath: string): Promise { +async function readAuthHash( + authFilePath: string, + readFileImpl: typeof readFile, +): Promise { try { - const content = await readFile(authFilePath, "utf-8"); + const content = await readFileImpl(authFilePath, "utf-8"); return hashFileContent(content); } catch { return null; @@ -119,9 +130,13 @@ export async function runCodexWithAutoRestart( const stderr = options.stderr ?? process.stderr; const cliManager = options.cliManager ?? createCliProcessManager({}); + const spawnImpl = options.spawnImpl ?? spawn; + const watchImpl = options.watchImpl ?? watch; + const readFileImpl = options.readFileImpl ?? readFile; + const attachProcessSignalHandlers = options.attachProcessSignalHandlers ?? true; let currentProcess: ChildProcess | null = null; - let currentAuthHash = await readAuthHash(authFilePath); + let currentAuthHash = await readAuthHash(authFilePath, readFileImpl); let restartCount = 0; let lastExitCode = 0; let isRestarting = false; @@ -134,7 +149,7 @@ export async function runCodexWithAutoRestart( function spawnCodex(): ChildProcess { debugLog(`run: spawning ${codexBinary} ${codexArgs.join(" ")}`); - const child = spawn(codexBinary, codexArgs, { + const child = spawnImpl(codexBinary, codexArgs, { stdio: "inherit", env: process.env, }); @@ -198,7 +213,7 @@ export async function runCodexWithAutoRestart( } // Update auth hash - currentAuthHash = await readAuthHash(authFilePath); + currentAuthHash = await readAuthHash(authFilePath, readFileImpl); // Spawn new process currentProcess = spawnCodex(); @@ -233,7 +248,7 @@ export async function runCodexWithAutoRestart( } try { - authWatcher = watch(authFilePath, { persistent: false }, () => { + authWatcher = watchImpl(authFilePath, { persistent: false }, () => { // Debounce: auth file may be written in multiple steps if (debounceTimer) { clearTimeout(debounceTimer); @@ -284,7 +299,7 @@ export async function runCodexWithAutoRestart( return; } - const newHash = await readAuthHash(authFilePath); + const newHash = await readAuthHash(authFilePath, readFileImpl); if (newHash && newHash !== currentAuthHash) { debugLog( `run: auth file changed (old=${currentAuthHash?.slice(0, 8)}, new=${newHash.slice(0, 8)})`, @@ -340,12 +355,14 @@ export async function runCodexWithAutoRestart( } }; - process.on("SIGINT", () => forwardSignal("SIGINT")); - process.on("SIGTERM", () => { - forwardSignal("SIGTERM"); - stopped = true; - cleanup(); - }); + if (attachProcessSignalHandlers) { + process.on("SIGINT", () => forwardSignal("SIGINT")); + process.on("SIGTERM", () => { + forwardSignal("SIGTERM"); + stopped = true; + cleanup(); + }); + } // ── Start ── diff --git a/src/codex-cli-watcher.ts b/src/codex-cli-watcher.ts index f98395c..80482f6 100644 --- a/src/codex-cli-watcher.ts +++ b/src/codex-cli-watcher.ts @@ -267,7 +267,37 @@ async function delay(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +async function delayOrAbort(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await delay(ms); + return; + } + + if (signal.aborted) { + return; + } + + await new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolve(); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + function isProcessAlive(pid: number): boolean { + if (pid === process.pid) { + return true; + } + try { process.kill(pid, 0); return true; @@ -515,7 +545,7 @@ export function createCliProcessManager(options: { debugLogger?.(`CLI poll: quota=${currentJson?.slice(0, 200)}`); // Wait for next poll interval - await delay(pollInterval); + await delayOrAbort(pollInterval, signal); } } finally { if (client) { @@ -540,7 +570,7 @@ export function createCliProcessManager(options: { // Exponential backoff, max 60s const backoffMs = Math.min(1_000 * Math.pow(2, attempt - 1), 60_000); - await delay(backoffMs); + await delayOrAbort(backoffMs, signal); } } } diff --git a/tests/codex-cli-runner.test.ts b/tests/codex-cli-runner.test.ts index 1e43f75..350a8f6 100644 --- a/tests/codex-cli-runner.test.ts +++ b/tests/codex-cli-runner.test.ts @@ -1,384 +1,164 @@ -/** - * Tests for codex-cli-runner.ts — the `codexm run` auto-restart wrapper. - */ +import { describe, expect, test } from "@rstest/core"; +import type { spawn } from "node:child_process"; +import type { watch } from "node:fs"; +import type { readFile } from "node:fs/promises"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// ── Mocks ── +import { runCodexWithAutoRestart } from "../src/codex-cli-runner.js"; function createMockChildProcess(pid = 12345) { const handlers = new Map(); + return { pid, exitCode: null as number | null, - kill: vi.fn(), - on: vi.fn((event: string, handler: Function) => { - if (!handlers.has(event)) handlers.set(event, []); - handlers.get(event)!.push(handler); - }), - once: vi.fn((event: string, handler: Function) => { - if (!handlers.has(event)) handlers.set(event, []); - handlers.get(event)!.push(handler); - }), - unref: vi.fn(), - _handlers: handlers, - _simulateExit(code: number) { + killCalls: [] as string[], + on(event: string, handler: Function) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + return this; + }, + once(event: string, handler: Function) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + return this; + }, + kill(signal: string) { + this.killCalls.push(signal); + return true; + }, + emitExit(code: number) { this.exitCode = code; - for (const h of handlers.get("exit") ?? []) h(code); + for (const handler of handlers.get("exit") ?? []) { + handler(code); + } }, }; } -const mockStderr = { write: vi.fn() } as unknown as NodeJS.WriteStream; - -const mockCliManager = { - registerProcess: vi.fn().mockResolvedValue(undefined), - getProcesses: vi.fn().mockReturnValue([]), - pruneStaleProcesses: vi.fn().mockResolvedValue([]), - pruneDeadProcesses: vi.fn().mockResolvedValue(undefined), - restartCliProcess: vi.fn().mockResolvedValue(undefined), - getTrackedProcesses: vi.fn().mockResolvedValue([]), - findRunningCliProcesses: vi.fn().mockResolvedValue([]), - readDirectQuota: vi.fn().mockResolvedValue(null), - readDirectAccount: vi.fn().mockResolvedValue(null), - watchCliQuotaSignals: vi.fn().mockResolvedValue(undefined), -}; - -let spawnMock: ReturnType; -let watchMock: ReturnType; -let readFileMock: ReturnType; -let watchCallback: ((...args: any[]) => void) | null = null; -let nextPid = 12345; - -vi.mock("node:child_process", () => ({ - spawn: (...args: any[]) => spawnMock(...args), -})); - -vi.mock("node:fs", () => ({ - watch: (...args: any[]) => watchMock(...args), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: (...args: any[]) => readFileMock(...args), - stat: vi.fn().mockResolvedValue({ mtimeMs: Date.now() }), -})); - -vi.mock("./codex-cli-watcher.js", () => ({ - createCliProcessManager: () => mockCliManager, -})); - -// Import after mocks -const { runCodexWithAutoRestart } = await import("./codex-cli-runner.js"); +function createMockCliManager() { + return { + registerProcess: async () => undefined, + }; +} describe("codex-cli-runner", () => { - let mockProcesses: ReturnType[]; - - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - mockProcesses = []; - nextPid = 12345; - watchCallback = null; - - spawnMock = vi.fn(() => { - const p = createMockChildProcess(nextPid++); - mockProcesses.push(p); - return p; - }); - - watchMock = vi.fn((_path: string, _opts: any, cb: Function) => { - watchCallback = cb as any; - return { - close: vi.fn(), - on: vi.fn(), - }; - }); + test("spawns codex with the provided args and returns the natural exit code", async () => { + const spawned: ReturnType[] = []; + const spawnCalls: Array<{ file: string; args: string[] }> = []; - // Default: auth file reads return different hashes on each call - let callCount = 0; - readFileMock = vi.fn(async () => { - callCount++; - return JSON.stringify({ token: `token-${callCount}` }); - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("spawns codex with correct args", async () => { const promise = runCodexWithAutoRestart({ codexArgs: ["--model", "o3"], codexBinary: "/usr/bin/codex", disableAuthWatch: true, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, + attachProcessSignalHandlers: false, + cliManager: createMockCliManager() as never, + readFileImpl: (async () => "token-1") as unknown as typeof readFile, + spawnImpl: ((file: string, argsOrOptions?: readonly string[] | object, _options?: object) => { + const args = Array.isArray(argsOrOptions) ? argsOrOptions : []; + spawnCalls.push({ file, args: [...(args ?? [])] }); + const child = createMockChildProcess(1001); + spawned.push(child); + return child as never; + }) as unknown as typeof spawn, }); - // Let the spawn happen - await vi.advanceTimersByTimeAsync(10); - - expect(spawnMock).toHaveBeenCalledWith( - "/usr/bin/codex", - ["--model", "o3"], - expect.objectContaining({ stdio: "inherit" }), - ); - - // Exit naturally - mockProcesses[0]._simulateExit(0); - const result = await promise; - expect(result.exitCode).toBe(0); - }); - - it("returns exit code when codex exits naturally", async () => { - const promise = runCodexWithAutoRestart({ - codexArgs: [], - disableAuthWatch: true, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(spawnCalls).toEqual([ + { + file: "/usr/bin/codex", + args: ["--model", "o3"], + }, + ]); + + spawned[0]!.emitExit(42); + await expect(promise).resolves.toEqual({ + exitCode: 42, + restartCount: 0, }); - - await vi.advanceTimersByTimeAsync(10); - mockProcesses[0]._simulateExit(42); - - const result = await promise; - expect(result.exitCode).toBe(42); - expect(result.restartCount).toBe(0); }); - it("restarts codex when auth file changes", async () => { - const promise = runCodexWithAutoRestart({ - codexArgs: [], - debounceMs: 100, - killTimeoutMs: 1000, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, - }); - - await vi.advanceTimersByTimeAsync(10); - expect(mockProcesses).toHaveLength(1); - - // Trigger auth file change - watchCallback?.("change", "auth.json"); + test("restarts codex when the watched auth file changes", async () => { + const spawned: ReturnType[] = []; + const spawnCalls: Array<{ file: string; args: string[] }> = []; + let watchCallback: (() => void) | undefined; + let readCount = 0; - // Advance past debounce - await vi.advanceTimersByTimeAsync(200); - - // Old process should receive SIGTERM - expect(mockProcesses[0].kill).toHaveBeenCalledWith("SIGTERM"); - - // Simulate the old process exiting after SIGTERM - mockProcesses[0]._simulateExit(0); - await vi.advanceTimersByTimeAsync(100); - - // New process should have been spawned - expect(mockProcesses).toHaveLength(2); - - // Clean up — exit the new process - mockProcesses[1]._simulateExit(0); - await vi.advanceTimersByTimeAsync(500); - - const result = await promise; - expect(result.restartCount).toBe(1); - }); - - it("increments restartCount on each restart", async () => { const promise = runCodexWithAutoRestart({ codexArgs: [], - debounceMs: 50, - killTimeoutMs: 500, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, + debounceMs: 10, + killTimeoutMs: 20, + attachProcessSignalHandlers: false, + cliManager: createMockCliManager() as never, + spawnImpl: ((file: string, argsOrOptions?: readonly string[] | object, _options?: object) => { + const args = Array.isArray(argsOrOptions) ? argsOrOptions : []; + spawnCalls.push({ file, args: [...(args ?? [])] }); + const child = createMockChildProcess(2000 + spawned.length); + spawned.push(child); + return child as never; + }) as unknown as typeof spawn, + watchImpl: ((_path: import("node:fs").PathLike, optionsOrListener?: unknown, maybeListener?: unknown) => { + watchCallback = typeof optionsOrListener === "function" + ? optionsOrListener as () => void + : maybeListener as (() => void) | undefined; + return { + close() { + return; + }, + on() { + return this; + }, + } as never; + }) as unknown as typeof watch, + readFileImpl: (async () => { + readCount += 1; + return `token-${readCount}`; + }) as unknown as typeof readFile, }); - await vi.advanceTimersByTimeAsync(10); - - // First restart - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(100); - mockProcesses[0]._simulateExit(0); - await vi.advanceTimersByTimeAsync(600); - - // Second restart - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(100); - mockProcesses[1]._simulateExit(0); - await vi.advanceTimersByTimeAsync(600); - - // Exit naturally - mockProcesses[2]._simulateExit(0); - await vi.advanceTimersByTimeAsync(500); - - const result = await promise; - expect(result.restartCount).toBe(2); - }); - - it("debounces rapid auth file changes", async () => { - const promise = runCodexWithAutoRestart({ - codexArgs: [], - debounceMs: 300, - killTimeoutMs: 1000, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, - }); - - await vi.advanceTimersByTimeAsync(10); - - // Fire 4 rapid watch events - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(50); - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(50); - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(50); - watchCallback?.("change", "auth.json"); - - // Advance past debounce from last event - await vi.advanceTimersByTimeAsync(400); - - // Should only have killed the process once - expect(mockProcesses[0].kill).toHaveBeenCalledTimes(1); - - // Simulate exit + cleanup - mockProcesses[0]._simulateExit(0); - await vi.advanceTimersByTimeAsync(200); - mockProcesses[1]._simulateExit(0); - await vi.advanceTimersByTimeAsync(500); - - const result = await promise; - expect(result.restartCount).toBe(1); - }); - - it("falls back to polling when fs.watch fails", async () => { - watchMock = vi.fn(() => { - throw new Error("fs.watch not supported"); - }); - - const promise = runCodexWithAutoRestart({ - codexArgs: [], - debounceMs: 50, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, - }); - - await vi.advanceTimersByTimeAsync(10); - expect(mockProcesses).toHaveLength(1); - - // Polling interval is 3000ms — advance past it - await vi.advanceTimersByTimeAsync(3500); - - // The poll should have checked the auth file - expect(readFileMock).toHaveBeenCalled(); - - // Clean up - mockProcesses[mockProcesses.length - 1]._simulateExit(0); - await vi.advanceTimersByTimeAsync(500); - await promise; - }); - - it("registers process in CLI manager", async () => { - const promise = runCodexWithAutoRestart({ - codexArgs: [], - accountId: "acc-123", - email: "test@example.com", - disableAuthWatch: true, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(spawned).toHaveLength(1); + + if (!watchCallback) { + throw new Error("watch callback not registered"); + } + watchCallback(); + await new Promise((resolve) => setTimeout(resolve, 15)); + expect(spawned[0]!.killCalls).toEqual(["SIGTERM"]); + + spawned[0]!.emitExit(0); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(spawned).toHaveLength(2); + expect(spawnCalls).toHaveLength(2); + + spawned[1]!.emitExit(0); + await expect(promise).resolves.toEqual({ + exitCode: 0, + restartCount: 1, }); - - await vi.advanceTimersByTimeAsync(10); - - expect(mockCliManager.registerProcess).toHaveBeenCalledWith( - expect.objectContaining({ - pid: 12345, - command: "codex", - }), - "acc-123", - "test@example.com", - ); - - mockProcesses[0]._simulateExit(0); - await promise; }); - it("respects AbortSignal", async () => { + test("stops the runner when aborted", async () => { const controller = new AbortController(); + const child = createMockChildProcess(3001); const promise = runCodexWithAutoRestart({ codexArgs: [], - signal: controller.signal, disableAuthWatch: true, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, + attachProcessSignalHandlers: false, + signal: controller.signal, + cliManager: createMockCliManager() as never, + readFileImpl: (async () => "token-1") as unknown as typeof readFile, + spawnImpl: (() => child as never) as unknown as typeof spawn, }); - await vi.advanceTimersByTimeAsync(10); - expect(mockProcesses).toHaveLength(1); - - // Abort + await new Promise((resolve) => setTimeout(resolve, 0)); controller.abort(); - await vi.advanceTimersByTimeAsync(500); - - // The child should have been killed - expect(mockProcesses[0].kill).toHaveBeenCalledWith("SIGTERM"); - - mockProcesses[0]._simulateExit(0); - await vi.advanceTimersByTimeAsync(200); - - const result = await promise; - expect(result.exitCode).toBe(0); - }); - - it("sends SIGKILL after timeout if SIGTERM doesn't work", async () => { - const promise = runCodexWithAutoRestart({ - codexArgs: [], - debounceMs: 50, - killTimeoutMs: 500, - stderr: mockStderr, - cliManager: mockCliManager as any, - debugLog: () => {}, - }); - - await vi.advanceTimersByTimeAsync(10); - - // Make the process ignore SIGTERM (exitCode stays null) - mockProcesses[0].kill.mockImplementation(() => { - // Don't actually exit — simulates a hung process + await expect(promise).resolves.toEqual({ + exitCode: 0, + restartCount: 0, }); - - // Trigger auth change - watchCallback?.("change", "auth.json"); - await vi.advanceTimersByTimeAsync(100); // past debounce - - // SIGTERM sent - expect(mockProcesses[0].kill).toHaveBeenCalledWith("SIGTERM"); - - // Advance past killTimeout - await vi.advanceTimersByTimeAsync(600); - - // SIGKILL should have been sent - expect(mockProcesses[0].kill).toHaveBeenCalledWith("SIGKILL"); - - // Simulate final exit - mockProcesses[0]._simulateExit(137); - await vi.advanceTimersByTimeAsync(200); - - // New process spawned - expect(mockProcesses.length).toBeGreaterThanOrEqual(2); - - // Exit the new one - mockProcesses[mockProcesses.length - 1]._simulateExit(0); - await vi.advanceTimersByTimeAsync(500); - - const result = await promise; - expect(result.restartCount).toBeGreaterThanOrEqual(1); + expect(child.killCalls).toEqual(["SIGTERM"]); }); }); diff --git a/tests/codex-cli-watcher.test.ts b/tests/codex-cli-watcher.test.ts index 480e3ca..091fe55 100644 --- a/tests/codex-cli-watcher.test.ts +++ b/tests/codex-cli-watcher.test.ts @@ -445,7 +445,7 @@ describe("createCliProcessManager", () => { }, }); - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 500)); controller.abort(); try { From c5b105a1b099c6728f3f56c9d4b7b358c7e2dbc0 Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 19:36:49 +0800 Subject: [PATCH 3/6] refactor(desktop): extract codex desktop launcher helpers --- src/codex-desktop-devtools.ts | 281 ++++++ src/codex-desktop-launch.ts | 1597 ++------------------------------- src/codex-desktop-process.ts | 102 +++ src/codex-desktop-runtime.ts | 882 ++++++++++++++++++ src/codex-desktop-shared.ts | 122 +++ src/codex-desktop-state.ts | 54 ++ src/codex-desktop-types.ts | 108 +++ 7 files changed, 1630 insertions(+), 1516 deletions(-) create mode 100644 src/codex-desktop-devtools.ts create mode 100644 src/codex-desktop-process.ts create mode 100644 src/codex-desktop-runtime.ts create mode 100644 src/codex-desktop-shared.ts create mode 100644 src/codex-desktop-state.ts create mode 100644 src/codex-desktop-types.ts diff --git a/src/codex-desktop-devtools.ts b/src/codex-desktop-devtools.ts new file mode 100644 index 0000000..9353487 --- /dev/null +++ b/src/codex-desktop-devtools.ts @@ -0,0 +1,281 @@ +import type { ManagedCodexDesktopState } from "./codex-desktop-types.js"; +import { + CODEX_LOCAL_HOST_ID, + isNonEmptyString, + isRecord, +} from "./codex-desktop-shared.js"; + +export interface FetchLikeResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export type FetchLike = ( + input: string | URL, + init?: RequestInit, +) => Promise; + +export interface WebSocketLike { + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onerror: ((event: unknown) => void) | null; + onclose: (() => void) | null; + send(data: string): void; + close(): void; +} + +export type CreateWebSocketLike = (url: string) => WebSocketLike; + +export function createDefaultWebSocket(url: string): WebSocketLike { + return new WebSocket(url) as unknown as WebSocketLike; +} + +function isDevtoolsTarget(value: unknown): value is { + type?: unknown; + url?: unknown; + webSocketDebuggerUrl?: unknown; +} { + return isRecord(value); +} + +export async function resolveLocalDevtoolsTarget( + fetchImpl: FetchLike, + state: ManagedCodexDesktopState, +): Promise { + const response = await fetchImpl( + `http://127.0.0.1:${state.remote_debugging_port}/json/list`, + ); + if (!response.ok) { + throw new Error( + `Failed to query Codex Desktop devtools targets (HTTP ${response.status}).`, + ); + } + + const targets = await response.json(); + if (!Array.isArray(targets)) { + throw new Error("Codex Desktop devtools target list was not an array."); + } + + const localTarget = targets.find((target) => { + if (!isDevtoolsTarget(target)) { + return false; + } + + return ( + target.type === "page" && + target.url === `app://-/index.html?hostId=${CODEX_LOCAL_HOST_ID}` && + isNonEmptyString(target.webSocketDebuggerUrl) + ); + }); + + if (!localTarget || !isNonEmptyString(localTarget.webSocketDebuggerUrl)) { + throw new Error("Current debug port is not connected to Codex Desktop."); + } + + return localTarget.webSocketDebuggerUrl; +} + +export function extractDevtoolsExceptionMessage(result: Record | null): string | null { + if (!result || !isRecord(result.exceptionDetails)) { + return null; + } + + const exceptionDetails = result.exceptionDetails; + const exception = isRecord(exceptionDetails.exception) ? exceptionDetails.exception : null; + const description = + typeof exception?.description === "string" && exception.description.trim() !== "" + ? exception.description.trim() + : typeof exception?.value === "string" && exception.value.trim() !== "" + ? exception.value.trim() + : typeof exceptionDetails.text === "string" && exceptionDetails.text.trim() !== "" + ? exceptionDetails.text.trim() + : null; + + if (!description) { + return null; + } + + const firstLine = description.split("\n")[0]?.trim() ?? description; + return firstLine || null; +} + +export async function evaluateDevtoolsExpression( + createWebSocketImpl: CreateWebSocketLike, + webSocketDebuggerUrl: string, + expression: string, + timeoutMs: number, +): Promise { + const socket = createWebSocketImpl(webSocketDebuggerUrl); + + await new Promise((resolve, reject) => { + const requestId = 1; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for Codex Desktop devtools response.")); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(); + }; + + socket.onopen = () => { + socket.send( + JSON.stringify({ + id: requestId, + method: "Runtime.evaluate", + params: { + expression, + awaitPromise: true, + }, + }), + ); + }; + + socket.onmessage = (event) => { + if (typeof event.data !== "string") { + return; + } + + let payload: unknown; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + if (!isRecord(payload) || payload.id !== requestId) { + return; + } + + if (isRecord(payload.error)) { + cleanup(); + reject(new Error(String(payload.error.message ?? "Codex Desktop devtools request failed."))); + return; + } + + const result = isRecord(payload.result) ? payload.result : null; + if (result && isRecord(result.exceptionDetails)) { + cleanup(); + reject( + new Error( + extractDevtoolsExceptionMessage(result) + ?? "Codex Desktop rejected the app-server restart request.", + ), + ); + return; + } + + cleanup(); + resolve(); + }; + + socket.onerror = () => { + cleanup(); + reject(new Error("Failed to communicate with Codex Desktop devtools.")); + }; + + socket.onclose = () => { + cleanup(); + reject(new Error("Codex Desktop devtools connection closed before replying.")); + }; + }); +} + +export async function evaluateDevtoolsExpressionWithResult( + createWebSocketImpl: CreateWebSocketLike, + webSocketDebuggerUrl: string, + expression: string, + timeoutMs: number, +): Promise { + const socket = createWebSocketImpl(webSocketDebuggerUrl); + + return await new Promise((resolve, reject) => { + const requestId = 1; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for Codex Desktop devtools response.")); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(); + }; + + socket.onopen = () => { + socket.send( + JSON.stringify({ + id: requestId, + method: "Runtime.evaluate", + params: { + expression, + awaitPromise: true, + returnByValue: true, + }, + }), + ); + }; + + socket.onmessage = (event) => { + if (typeof event.data !== "string") { + return; + } + + let payload: unknown; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + if (!isRecord(payload) || payload.id !== requestId) { + return; + } + + if (isRecord(payload.error)) { + cleanup(); + reject(new Error(String(payload.error.message ?? "Codex Desktop devtools request failed."))); + return; + } + + const result = isRecord(payload.result) ? payload.result : null; + if (!result || !isRecord(result.result)) { + cleanup(); + reject(new Error("Codex Desktop devtools request returned an invalid result.")); + return; + } + + if (isRecord(result.exceptionDetails)) { + cleanup(); + reject( + new Error( + extractDevtoolsExceptionMessage(result) ?? "Codex Desktop rejected the devtools request.", + ), + ); + return; + } + + cleanup(); + resolve(result.result.value as T); + }; + + socket.onerror = () => { + cleanup(); + reject(new Error("Failed to communicate with Codex Desktop devtools.")); + }; + + socket.onclose = () => { + cleanup(); + reject(new Error("Codex Desktop devtools connection closed before replying.")); + }; + }); +} diff --git a/src/codex-desktop-launch.ts b/src/codex-desktop-launch.ts index 95deb27..8fd72c8 100644 --- a/src/codex-desktop-launch.ts +++ b/src/codex-desktop-launch.ts @@ -1,1530 +1,95 @@ -import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { createCodexDirectClient, type CodexDirectClient, } from "./codex-direct-client.js"; +import { + createDefaultWebSocket, + evaluateDevtoolsExpression, + evaluateDevtoolsExpressionWithResult, + resolveLocalDevtoolsTarget, + type CreateWebSocketLike, + type FetchLike, +} from "./codex-desktop-devtools.js"; +import { + isManagedDesktopProcess, + launchManagedDesktopProcess, + pathExistsViaStat, + readProcessParentAndCommand, + type LaunchProcessLike, +} from "./codex-desktop-process.js"; +import { + CODEX_APP_NAME, + CODEX_APP_SERVER_RESTART_EXPRESSION, + CODEX_BINARY_SUFFIX, + DEFAULT_CODEX_DESKTOP_STATE_PATH, + DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, + DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, + DEFAULT_WATCH_HEALTH_CHECK_INTERVAL_MS, + DEFAULT_WATCH_HEALTH_CHECK_TIMEOUT_MS, + DEFAULT_WATCH_RECONNECT_DELAY_MS, + DEVTOOLS_REQUEST_TIMEOUT_MS, + DEVTOOLS_SWITCH_TIMEOUT_BUFFER_MS, + delay, + isRecord, + toErrorMessage, + waitForPromiseOrAbort, +} from "./codex-desktop-shared.js"; +import { ensureStateDirectory, parseManagedState } from "./codex-desktop-state.js"; +import { + buildManagedCurrentAccountExpression, + buildManagedCurrentQuotaExpression, + buildManagedSwitchExpression, + buildManagedWatchProbeExpression, + extractProbeConsolePayload, + extractRpcActivitySignal, + extractRpcQuotaSignal, + extractRuntimeConsoleText, + formatBridgeDebugLine, + normalizeBridgeProbePayload, + normalizeRuntimeAccountSnapshot, + normalizeRuntimeQuotaSnapshot, +} from "./codex-desktop-runtime.js"; +import type { + CodexDesktopLauncher, + ExecFileLike, + ManagedQuotaSignal, + ManagedWatchActivitySignal, + ManagedWatchStatusEvent, + ManagedCodexDesktopState, + RunningCodexDesktop, + RuntimeAccountSnapshot, + RuntimeQuotaSnapshot, + RuntimeReadResult, +} from "./codex-desktop-types.js"; export type { CodexDirectClient } from "./codex-direct-client.js"; +export type { + CodexDesktopLauncher, + ExecFileLike, + ManagedCodexDesktopState, + ManagedCurrentAccountSnapshot, + ManagedCurrentQuotaSnapshot, + ManagedQuotaSignal, + ManagedWatchActivitySignal, + ManagedWatchStatusEvent, + RunningCodexDesktop, + RuntimeAccountSnapshot, + RuntimeQuotaSnapshot, + RuntimeReadResult, + RuntimeReadSource, +} from "./codex-desktop-types.js"; +export { + DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, + DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, +} from "./codex-desktop-shared.js"; const execFile = promisify(execFileCallback); -export const DEFAULT_CODEX_REMOTE_DEBUGGING_PORT = 39223; -const DEFAULT_CODEX_DESKTOP_STATE_PATH = join( - homedir(), - ".codex-team", - "desktop-state.json", -); -const CODEX_BINARY_SUFFIX = "/Contents/MacOS/Codex"; -const CODEX_APP_NAME = "Codex"; -const CODEX_LOCAL_HOST_ID = "local"; -export const DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS = 120_000; -const CODEXM_WATCH_CONSOLE_PREFIX = "__codexm_watch__"; -const DEVTOOLS_REQUEST_TIMEOUT_MS = 5_000; -const DEVTOOLS_SWITCH_TIMEOUT_BUFFER_MS = 10_000; -const DEFAULT_WATCH_RECONNECT_DELAY_MS = 1_000; -const DEFAULT_WATCH_HEALTH_CHECK_INTERVAL_MS = 5_000; -const DEFAULT_WATCH_HEALTH_CHECK_TIMEOUT_MS = 3_000; - -function buildCodexDesktopGuardExpression(): string { - return ` - const expectedHref = ${JSON.stringify(`app://-/index.html?hostId=${CODEX_LOCAL_HOST_ID}`)}; - const actualHref = - typeof window !== "undefined" && - window.location && - typeof window.location.href === "string" - ? window.location.href - : null; - const hasBridge = - typeof window !== "undefined" && - !!window.electronBridge && - typeof window.electronBridge.sendMessageFromView === "function"; - - if (actualHref !== expectedHref || !hasBridge) { - throw new Error("Connected debug console target is not Codex Desktop."); - } -`; -} - -const CODEX_APP_SERVER_RESTART_EXPRESSION = `(async () => {${buildCodexDesktopGuardExpression()} - await window.electronBridge.sendMessageFromView({ type: "codex-app-server-restart", hostId: "local" }); -})()`; - -export interface ExecFileLike { - ( - file: string, - args?: readonly string[], - ): Promise<{ stdout: string; stderr: string }>; -} - -interface FetchLikeResponse { - ok: boolean; - status: number; - json(): Promise; -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise; - -interface WebSocketLike { - onopen: (() => void) | null; - onmessage: ((event: { data: unknown }) => void) | null; - onerror: ((event: unknown) => void) | null; - onclose: (() => void) | null; - send(data: string): void; - close(): void; -} - -type CreateWebSocketLike = (url: string) => WebSocketLike; -type LaunchProcessLike = (options: { - appPath: string; - binaryPath: string; - args: readonly string[]; -}) => Promise; - -export interface RunningCodexDesktop { - pid: number; - command: string; -} - -export interface ManagedCodexDesktopState { - pid: number; - app_path: string; - remote_debugging_port: number; - managed_by_codexm: true; - started_at: string; -} - -export interface ManagedQuotaSignal { - requestId: string; - url: string; - status: number | null; - reason: "rpc_response" | "rpc_notification"; - bodySnippet: string | null; - shouldAutoSwitch: boolean; - quota: RuntimeQuotaSnapshot | null; -} - -export interface ManagedWatchActivitySignal { - requestId: string; - method: string; - reason: "quota_dirty" | "turn_completed"; - bodySnippet: string | null; -} - -export interface ManagedWatchStatusEvent { - type: "disconnected" | "reconnected"; - attempt: number; - error: string | null; -} - -export interface RuntimeQuotaSnapshot { - plan_type: string | null; - credits_balance: number | null; - unlimited: boolean; - five_hour: { - used_percent: number; - window_seconds: number; - reset_at: string | null; - } | null; - one_week: { - used_percent: number; - window_seconds: number; - reset_at: string | null; - } | null; - fetched_at: string; -} - -export interface RuntimeAccountSnapshot { - auth_mode: string | null; - email: string | null; - plan_type: string | null; - requires_openai_auth: boolean | null; -} - -export type RuntimeReadSource = "desktop" | "direct"; - -export interface RuntimeReadResult { - snapshot: TSnapshot; - source: RuntimeReadSource; -} - -export type ManagedCurrentQuotaSnapshot = RuntimeQuotaSnapshot; -export type ManagedCurrentAccountSnapshot = RuntimeAccountSnapshot; - -export interface CodexDesktopLauncher { - findInstalledApp(): Promise; - listRunningApps(): Promise; - isRunningInsideDesktopShell(): Promise; - quitRunningApps(options?: { force?: boolean }): Promise; - launch(appPath: string): Promise; - readManagedState(): Promise; - writeManagedState(state: ManagedCodexDesktopState): Promise; - clearManagedState(): Promise; - isManagedDesktopRunning(): Promise; - readDirectRuntimeAccount(): Promise; - readDirectRuntimeQuota(): Promise; - readCurrentRuntimeAccountResult(): Promise | null>; - readCurrentRuntimeQuotaResult(): Promise | null>; - readCurrentRuntimeAccount(): Promise; - readCurrentRuntimeQuota(): Promise; - readManagedCurrentAccount(): Promise; - readManagedCurrentQuota(): Promise; - applyManagedSwitch(options?: { - force?: boolean; - timeoutMs?: number; - signal?: AbortSignal; - }): Promise; - watchManagedQuotaSignals(options?: { - signal?: AbortSignal; - debugLogger?: (line: string) => void; - onQuotaSignal?: (signal: ManagedQuotaSignal) => Promise | void; - onActivitySignal?: (signal: ManagedWatchActivitySignal) => Promise | void; - onStatus?: (event: ManagedWatchStatusEvent) => Promise | void; - }): Promise; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - -async function delay(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -function createAbortError(): Error { - const error = new Error("Managed Codex Desktop refresh was interrupted."); - error.name = "AbortError"; - return error; -} - -async function waitForPromiseOrAbort( - promise: Promise, - signal: AbortSignal | undefined, -): Promise { - if (!signal) { - return await promise; - } - - if (signal.aborted) { - throw createAbortError(); - } - - return await new Promise((resolve, reject) => { - const onAbort = () => { - cleanup(); - reject(createAbortError()); - }; - - const cleanup = () => { - signal.removeEventListener("abort", onAbort); - }; - - signal.addEventListener("abort", onAbort, { once: true }); - - void promise.then( - (value) => { - cleanup(); - resolve(value); - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} - -async function pathExistsViaStat( - execFileImpl: ExecFileLike, - path: string, -): Promise { - try { - await execFileImpl("stat", ["-f", "%N", path]); - return true; - } catch { - return false; - } -} - -async function readProcessParentAndCommand( - execFileImpl: ExecFileLike, - pid: number, -): Promise<{ ppid: number; command: string } | null> { - try { - const { stdout } = await execFileImpl("ps", ["-o", "ppid=,command=", "-p", String(pid)]); - const line = stdout - .split("\n") - .map((entry) => entry.trim()) - .find((entry) => entry !== ""); - if (!line) { - return null; - } - - const match = line.match(/^(\d+)\s+(.+)$/); - if (!match) { - return null; - } - - return { - ppid: Number(match[1]), - command: match[2], - }; - } catch { - return null; - } -} - -function parseManagedState(raw: string): ManagedCodexDesktopState | null { - if (raw.trim() === "") { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!isRecord(parsed)) { - return null; - } - - const pid = parsed.pid; - const appPath = parsed.app_path; - const remoteDebuggingPort = parsed.remote_debugging_port; - const managedByCodexm = parsed.managed_by_codexm; - const startedAt = parsed.started_at; - - if ( - typeof pid !== "number" || - !Number.isInteger(pid) || - pid <= 0 || - !isNonEmptyString(appPath) || - typeof remoteDebuggingPort !== "number" || - !Number.isInteger(remoteDebuggingPort) || - remoteDebuggingPort <= 0 || - managedByCodexm !== true || - !isNonEmptyString(startedAt) - ) { - return null; - } - - return { - pid, - app_path: appPath, - remote_debugging_port: remoteDebuggingPort, - managed_by_codexm: true, - started_at: startedAt, - }; -} - -async function ensureStateDirectory(statePath: string): Promise { - await mkdir(dirname(statePath), { recursive: true, mode: 0o700 }); -} - -function createDefaultWebSocket(url: string): WebSocketLike { - return new WebSocket(url) as unknown as WebSocketLike; -} - -function isDevtoolsTarget(value: unknown): value is { - type?: unknown; - url?: unknown; - webSocketDebuggerUrl?: unknown; -} { - return isRecord(value); -} - -function toErrorMessage(value: unknown, fallback: string): Error { - if (value instanceof Error) { - return value; - } - - if (isRecord(value) && typeof value.message === "string") { - return new Error(value.message); - } - - if (typeof value === "string" && value.trim() !== "") { - return new Error(value); - } - - return new Error(fallback); -} - -async function launchManagedDesktopProcess(options: { - appPath: string; - binaryPath: string; - args: readonly string[]; -}): Promise { - await new Promise((resolve, reject) => { - const child = spawnCallback(options.binaryPath, [...options.args], { - cwd: options.appPath, - detached: true, - stdio: "ignore", - }); - - let settled = false; - - const settle = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - callback(); - }; - - child.once("error", (error) => { - settle(() => reject(error)); - }); - - child.once("spawn", () => { - child.unref(); - settle(resolve); - }); - }); -} - -function isManagedDesktopProcess( - runningApps: RunningCodexDesktop[], - state: ManagedCodexDesktopState, -): boolean { - const expectedBinaryPath = `${state.app_path}${CODEX_BINARY_SUFFIX}`; - const expectedPort = `--remote-debugging-port=${state.remote_debugging_port}`; - - return runningApps.some( - (entry) => - entry.pid === state.pid && - entry.command.includes(expectedBinaryPath) && - entry.command.includes(expectedPort), - ); -} - -async function resolveLocalDevtoolsTarget( - fetchImpl: FetchLike, - state: ManagedCodexDesktopState, -): Promise { - const response = await fetchImpl( - `http://127.0.0.1:${state.remote_debugging_port}/json/list`, - ); - if (!response.ok) { - throw new Error( - `Failed to query Codex Desktop devtools targets (HTTP ${response.status}).`, - ); - } - - const targets = await response.json(); - if (!Array.isArray(targets)) { - throw new Error("Codex Desktop devtools target list was not an array."); - } - - const localTarget = targets.find((target) => { - if (!isDevtoolsTarget(target)) { - return false; - } - - return ( - target.type === "page" && - target.url === `app://-/index.html?hostId=${CODEX_LOCAL_HOST_ID}` && - isNonEmptyString(target.webSocketDebuggerUrl) - ); - }); - - if (!localTarget || !isNonEmptyString(localTarget.webSocketDebuggerUrl)) { - throw new Error("Current debug port is not connected to Codex Desktop."); - } - - return localTarget.webSocketDebuggerUrl; -} - -function extractDevtoolsExceptionMessage(result: Record | null): string | null { - if (!result || !isRecord(result.exceptionDetails)) { - return null; - } - - const exceptionDetails = result.exceptionDetails; - const exception = isRecord(exceptionDetails.exception) ? exceptionDetails.exception : null; - const description = - typeof exception?.description === "string" && exception.description.trim() !== "" - ? exception.description.trim() - : typeof exception?.value === "string" && exception.value.trim() !== "" - ? exception.value.trim() - : typeof exceptionDetails.text === "string" && exceptionDetails.text.trim() !== "" - ? exceptionDetails.text.trim() - : null; - - if (!description) { - return null; - } - - const firstLine = description.split("\n")[0]?.trim() ?? description; - return firstLine || null; -} - -function normalizeBodySnippet(body: string | null): string | null { - if (!body) { - return null; - } - - return body.slice(0, 2_000); -} - -function hasStructuredQuotaError(value: unknown, depth = 0): boolean { - if (depth > 8) { - return false; - } - - if (Array.isArray(value)) { - return value.some((entry) => hasStructuredQuotaError(entry, depth + 1)); - } - - if (!isRecord(value)) { - return false; - } - - if (value.codexErrorInfo === "usageLimitExceeded") { - return true; - } - - const exactErrorCodeCandidates = [ - value.code, - value.errorCode, - value.error_code, - value.type, - ]; - if (exactErrorCodeCandidates.some((entry) => entry === "insufficient_quota")) { - return true; - } - - return Object.values(value).some((entry) => hasStructuredQuotaError(entry, depth + 1)); -} - -function buildManagedWatchProbeExpression(): string { - return `(() => { - ${buildCodexDesktopGuardExpression()} - const prefix = ${JSON.stringify(CODEXM_WATCH_CONSOLE_PREFIX)}; - const globalState = window.__codexmWatchState ?? { installed: false }; - - if (globalState.installed) { - return { installed: true }; - } - - globalState.installed = true; - window.__codexmWatchState = globalState; - - const emitBridge = (direction, event) => { - if (!event || typeof event !== "object" || Array.isArray(event)) { - return; - } - const type = typeof event.type === "string" ? event.type : ""; - if (!type.startsWith("mcp-")) { - return; - } - console.debug(prefix + JSON.stringify({ kind: "bridge", direction, event })); - }; - window.addEventListener("codex-message-from-view", (event) => { - emitBridge("from_view", event.detail); - }); - window.addEventListener("message", (event) => { - emitBridge("for_view", event.data); - }); - - return { installed: true }; -})()`; -} - -function buildManagedCurrentQuotaExpression(): string { - return `(async () => { - ${buildCodexDesktopGuardExpression()} - const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; - const rpcTimeoutMs = 5000; - - const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); - const toError = (value, fallback) => { - if (value instanceof Error) { - return value; - } - - const message = - typeof value === "string" - ? value - : isRecord(value) && typeof value.message === "string" - ? value.message - : fallback; - return new Error(message); - }; - - const postMessage = async (message) => { - if (!window.electronBridge || typeof window.electronBridge.sendMessageFromView !== "function") { - throw new Error("Codex Desktop bridge is unavailable."); - } - - await window.electronBridge.sendMessageFromView(message); - }; - - const pendingResponses = new Map(); - let nextRequestId = 1; - - const onMessage = (event) => { - const data = event?.data; - if (!isRecord(data) || data.type !== "mcp-response" || !isRecord(data.message)) { - return; - } - - const responseId = - typeof data.message.id === "string" || typeof data.message.id === "number" - ? String(data.message.id) - : null; - if (!responseId) { - return; - } - - const pending = pendingResponses.get(responseId); - if (!pending) { - return; - } - - pendingResponses.delete(responseId); - window.clearTimeout(pending.timeoutHandle); - - if (isRecord(data.message.error)) { - pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); - return; - } - - pending.resolve(data.message.result); - }; - - window.addEventListener("message", onMessage); - - const sendRpcRequest = async (method, params = {}) => { - const requestId = "codexm-current-" + String(nextRequestId++); - - return await new Promise((resolve, reject) => { - const timeoutHandle = window.setTimeout(() => { - pendingResponses.delete(requestId); - reject(new Error("Timed out waiting for Codex Desktop bridge response.")); - }, rpcTimeoutMs); - - pendingResponses.set(requestId, { - resolve, - reject, - timeoutHandle, - }); - - void postMessage({ - type: "mcp-request", - hostId, - request: { - id: requestId, - method, - params, - }, - }).catch((error) => { - pendingResponses.delete(requestId); - window.clearTimeout(timeoutHandle); - reject(toError(error, "Failed to send Codex Desktop bridge request.")); - }); - }); - }; - - try { - const result = await sendRpcRequest("account/rateLimits/read", {}); - return isRecord(result) ? result : null; - } finally { - for (const pending of pendingResponses.values()) { - window.clearTimeout(pending.timeoutHandle); - } - pendingResponses.clear(); - window.removeEventListener("message", onMessage); - } -})()`; -} - -function buildManagedCurrentAccountExpression(): string { - return `(async () => { - ${buildCodexDesktopGuardExpression()} - const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; - const rpcTimeoutMs = ${DEVTOOLS_REQUEST_TIMEOUT_MS}; - const pendingResponses = new Map(); - let nextRequestId = 1; - - const toError = (value, fallback) => { - if (value instanceof Error) { - return value; - } - if (value && typeof value === "object" && typeof value.message === "string") { - return new Error(value.message); - } - if (typeof value === "string" && value.trim() !== "") { - return new Error(value); - } - return new Error(fallback); - }; - - const postMessage = async (message) => { - if ( - typeof window === "undefined" || - !window.electronBridge || - typeof window.electronBridge.sendMessageFromView !== "function" - ) { - throw new Error("Codex Desktop bridge is unavailable."); - } - - return await window.electronBridge.sendMessageFromView(message); - }; - - const onMessage = (event) => { - const data = event && typeof event === "object" ? event.data : null; - if (!data || typeof data !== "object") { - return; - } - - if (data.hostId !== hostId) { - return; - } - - if (data.type === "mcp-response" && data.message && typeof data.message.id === "string") { - const pending = pendingResponses.get(data.message.id); - if (!pending) { - return; - } - - pendingResponses.delete(data.message.id); - window.clearTimeout(pending.timeoutHandle); - - if (data.message.error) { - pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); - return; - } - - pending.resolve(data.message.result); - } - }; - - window.addEventListener("message", onMessage); - - const sendRpcRequest = async (method, params = {}) => { - const requestId = "codexm-current-account-" + String(nextRequestId++); - - return await new Promise((resolve, reject) => { - const timeoutHandle = window.setTimeout(() => { - pendingResponses.delete(requestId); - reject(new Error("Timed out waiting for Codex Desktop bridge response.")); - }, rpcTimeoutMs); - - pendingResponses.set(requestId, { - resolve, - reject, - timeoutHandle, - }); - - void postMessage({ - type: "mcp-request", - hostId, - request: { - id: requestId, - method, - params, - }, - }).catch((error) => { - pendingResponses.delete(requestId); - window.clearTimeout(timeoutHandle); - reject(toError(error, "Failed to send Codex Desktop bridge request.")); - }); - }); - }; - - try { - const result = await sendRpcRequest("account/read", { refreshToken: false }); - return result && typeof result === "object" ? result : null; - } finally { - for (const pending of pendingResponses.values()) { - window.clearTimeout(pending.timeoutHandle); - } - pendingResponses.clear(); - window.removeEventListener("message", onMessage); - } -})()`; -} - -function epochSecondsToIsoString(value: unknown): string | null { - if (typeof value !== "number" || !Number.isFinite(value)) { - return null; - } - - return new Date(value * 1000).toISOString(); -} - -function normalizeManagedQuotaWindow( - value: unknown, - fallbackWindowSeconds: number, -): RuntimeQuotaSnapshot["five_hour"] { - if (!isRecord(value) || typeof value.usedPercent !== "number") { - return null; - } - - const windowDurationMins = - typeof value.windowDurationMins === "number" && Number.isFinite(value.windowDurationMins) - ? value.windowDurationMins - : null; - - return { - used_percent: value.usedPercent, - window_seconds: windowDurationMins === null ? fallbackWindowSeconds : windowDurationMins * 60, - reset_at: epochSecondsToIsoString(value.resetsAt), - }; -} - -function normalizeRuntimeQuotaSnapshot(value: unknown): RuntimeQuotaSnapshot | null { - if (!isRecord(value)) { - return null; - } - - const rateLimits = isRecord(value.rateLimits) ? value.rateLimits : null; - if (!rateLimits) { - return null; - } - - const credits = isRecord(rateLimits.credits) ? rateLimits.credits : null; - const balanceValue = credits?.balance; - const creditsBalance = - typeof balanceValue === "string" && balanceValue.trim() !== "" - ? Number(balanceValue) - : typeof balanceValue === "number" - ? balanceValue - : null; - - return { - plan_type: typeof rateLimits.planType === "string" ? rateLimits.planType : null, - credits_balance: Number.isFinite(creditsBalance) ? creditsBalance : null, - unlimited: credits?.unlimited === true, - five_hour: normalizeManagedQuotaWindow(rateLimits.primary ?? rateLimits.primaryWindow, 18_000), - one_week: normalizeManagedQuotaWindow( - rateLimits.secondary ?? rateLimits.secondaryWindow, - 604_800, - ), - fetched_at: new Date().toISOString(), - }; -} - -function normalizeRuntimeAccountSnapshot(value: unknown): RuntimeAccountSnapshot | null { - if (!isRecord(value)) { - return null; - } - - const account = isRecord(value.account) ? value.account : null; - const accountType = account?.type; - const authMode = - accountType === "apiKey" - ? "apikey" - : accountType === "chatgpt" - ? "chatgpt" - : null; - - return { - auth_mode: authMode, - email: typeof account?.email === "string" ? account.email : null, - plan_type: typeof account?.planType === "string" ? account.planType : null, - requires_openai_auth: - typeof value.requiresOpenaiAuth === "boolean" ? value.requiresOpenaiAuth : null, - }; -} - -interface ProbeConsolePayload { - kind?: unknown; - message?: unknown; - event?: unknown; - direction?: unknown; -} - -interface BridgeProbePayload { - kind: "bridge"; - direction: string | null; - event: Record; -} - -function extractRuntimeConsoleText(payload: Record): string | null { - const args = Array.isArray(payload.args) ? payload.args : []; - const parts = args - .map((arg) => { - if (!isRecord(arg)) { - return null; - } - - if (typeof arg.value === "string") { - return arg.value; - } - if (typeof arg.unserializableValue === "string") { - return arg.unserializableValue; - } - if (typeof arg.description === "string") { - return arg.description; - } - - return null; - }) - .filter((value): value is string => typeof value === "string" && value.trim() !== ""); - - if (parts.length === 0) { - return null; - } - - return parts.join(" "); -} - -function extractProbeConsolePayload(message: string | null): ProbeConsolePayload | null { - if (!message || !message.startsWith(CODEXM_WATCH_CONSOLE_PREFIX)) { - return null; - } - - const rawPayload = message.slice(CODEXM_WATCH_CONSOLE_PREFIX.length); - try { - const parsed = JSON.parse(rawPayload) as ProbeConsolePayload; - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -function normalizeBridgeProbePayload(payload: ProbeConsolePayload | null): BridgeProbePayload | null { - if (payload?.kind !== "bridge" || !isRecord(payload.event)) { - return null; - } - - return { - kind: "bridge", - direction: typeof payload.direction === "string" ? payload.direction : null, - event: payload.event, - }; -} - -function formatBridgeDebugLine(payload: BridgeProbePayload): string { - return JSON.stringify({ - method: "Bridge.message", - params: { - direction: payload.direction, - event: payload.event, - }, - }); -} - -function stringifySnippet(value: unknown): string | null { - try { - return normalizeBodySnippet(JSON.stringify(value)); - } catch { - return null; - } -} - -function hasExhaustedRateLimit(value: unknown, depth = 0): boolean { - if (depth > 8) { - return false; - } - - if (Array.isArray(value)) { - return value.some((entry) => hasExhaustedRateLimit(entry, depth + 1)); - } - - if (!isRecord(value)) { - return false; - } - - const usedPercent = value.usedPercent ?? value.used_percent; - if (typeof usedPercent === "number" && usedPercent >= 100) { - return true; - } - - return Object.values(value).some((entry) => hasExhaustedRateLimit(entry, depth + 1)); -} - -function buildRpcQuotaSignal(options: { - event: Record; - requestId: string; - method: string | null; - reason: "rpc_response" | "rpc_notification"; - shouldAutoSwitch: boolean; - quota: RuntimeQuotaSnapshot | null; -}): ManagedQuotaSignal { - return { - requestId: options.requestId, - url: options.method ? `mcp:${options.method}` : "mcp", - status: null, - reason: options.reason, - bodySnippet: stringifySnippet(options.event), - shouldAutoSwitch: options.shouldAutoSwitch, - quota: options.quota, - }; -} - -function extractRpcQuotaSignal( - payload: BridgeProbePayload | null, - rpcRequestMethods: Map, -): ManagedQuotaSignal | null { - if (!payload) { - return null; - } - - const event = payload.event; - const eventType = typeof event.type === "string" ? event.type : null; - if (!eventType?.startsWith("mcp-")) { - return null; - } - - if (eventType === "mcp-request") { - const request = isRecord(event.request) ? event.request : null; - const requestId = - typeof request?.id === "string" || typeof request?.id === "number" - ? String(request.id) - : null; - const method = typeof request?.method === "string" ? request.method : null; - if (requestId && method) { - rpcRequestMethods.set(requestId, method); - } - return null; - } - - if (eventType === "mcp-notification") { - const method = typeof event.method === "string" ? event.method : null; - if (method === "account/rateLimits/updated") { - return null; - } - if ( - method === "error" && hasStructuredQuotaError(event.params) - ) { - return buildRpcQuotaSignal({ - event, - requestId: `rpc:notification:${method ?? "unknown"}`, - method, - reason: "rpc_notification", - shouldAutoSwitch: true, - quota: null, - }); - } - return null; - } - - if (eventType !== "mcp-response") { - return null; - } - - const message = isRecord(event.message) ? event.message : null; - const responseId = - typeof message?.id === "string" || typeof message?.id === "number" - ? String(message.id) - : "unknown"; - const method = rpcRequestMethods.get(responseId) ?? null; - if (method === "account/rateLimits/read" && isRecord(message?.result)) { - if (responseId.startsWith("codexm-current-")) { - return null; - } - - return buildRpcQuotaSignal({ - event, - requestId: `rpc:${responseId}`, - method, - reason: "rpc_response", - shouldAutoSwitch: hasExhaustedRateLimit(message?.result), - quota: normalizeRuntimeQuotaSnapshot(message?.result), - }); - } - - if ( - hasStructuredQuotaError(message?.error) - ) { - return buildRpcQuotaSignal({ - event, - requestId: `rpc:${responseId}`, - method, - reason: "rpc_response", - shouldAutoSwitch: true, - quota: null, - }); - } - - return null; -} - -function extractRpcActivitySignal( - payload: BridgeProbePayload | null, -): ManagedWatchActivitySignal | null { - if (!payload) { - return null; - } - - const event = payload.event; - const eventType = typeof event.type === "string" ? event.type : null; - if (eventType !== "mcp-notification") { - return null; - } - - const method = typeof event.method === "string" ? event.method : null; - if (method === "account/rateLimits/updated") { - return { - requestId: `rpc:notification:${method}`, - method, - reason: "quota_dirty", - bodySnippet: stringifySnippet(event), - }; - } - - if (method === "turn/completed") { - return { - requestId: `rpc:notification:${method}`, - method, - reason: "turn_completed", - bodySnippet: stringifySnippet(event), - }; - } - - return null; -} - -async function evaluateDevtoolsExpression( - createWebSocketImpl: CreateWebSocketLike, - webSocketDebuggerUrl: string, - expression: string, - timeoutMs: number, -): Promise { - const socket = createWebSocketImpl(webSocketDebuggerUrl); - - await new Promise((resolve, reject) => { - const requestId = 1; - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("Timed out waiting for Codex Desktop devtools response.")); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(); - }; - - socket.onopen = () => { - socket.send( - JSON.stringify({ - id: requestId, - method: "Runtime.evaluate", - params: { - expression, - awaitPromise: true, - }, - }), - ); - }; - - socket.onmessage = (event) => { - if (typeof event.data !== "string") { - return; - } - - let payload: unknown; - try { - payload = JSON.parse(event.data); - } catch { - return; - } - - if (!isRecord(payload) || payload.id !== requestId) { - return; - } - - if (isRecord(payload.error)) { - cleanup(); - reject(new Error(String(payload.error.message ?? "Codex Desktop devtools request failed."))); - return; - } - - const result = isRecord(payload.result) ? payload.result : null; - if (result && isRecord(result.exceptionDetails)) { - cleanup(); - reject( - new Error( - extractDevtoolsExceptionMessage(result) - ?? "Codex Desktop rejected the app-server restart request.", - ), - ); - return; - } - - cleanup(); - resolve(); - }; - - socket.onerror = () => { - cleanup(); - reject(new Error("Failed to communicate with Codex Desktop devtools.")); - }; - - socket.onclose = () => { - cleanup(); - reject(new Error("Codex Desktop devtools connection closed before replying.")); - }; - }); -} - -async function evaluateDevtoolsExpressionWithResult( - createWebSocketImpl: CreateWebSocketLike, - webSocketDebuggerUrl: string, - expression: string, - timeoutMs: number, -): Promise { - const socket = createWebSocketImpl(webSocketDebuggerUrl); - - return await new Promise((resolve, reject) => { - const requestId = 1; - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("Timed out waiting for Codex Desktop devtools response.")); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(); - }; - - socket.onopen = () => { - socket.send( - JSON.stringify({ - id: requestId, - method: "Runtime.evaluate", - params: { - expression, - awaitPromise: true, - returnByValue: true, - }, - }), - ); - }; - - socket.onmessage = (event) => { - if (typeof event.data !== "string") { - return; - } - - let payload: unknown; - try { - payload = JSON.parse(event.data); - } catch { - return; - } - - if (!isRecord(payload) || payload.id !== requestId) { - return; - } - - if (isRecord(payload.error)) { - cleanup(); - reject(new Error(String(payload.error.message ?? "Codex Desktop devtools request failed."))); - return; - } - - const result = isRecord(payload.result) ? payload.result : null; - if (!result || !isRecord(result.result)) { - cleanup(); - reject(new Error("Codex Desktop devtools request returned an invalid result.")); - return; - } - - if (isRecord(result.exceptionDetails)) { - cleanup(); - reject( - new Error( - extractDevtoolsExceptionMessage(result) ?? "Codex Desktop rejected the devtools request.", - ), - ); - return; - } - - cleanup(); - resolve(result.result.value as T); - }; - - socket.onerror = () => { - cleanup(); - reject(new Error("Failed to communicate with Codex Desktop devtools.")); - }; - - socket.onclose = () => { - cleanup(); - reject(new Error("Codex Desktop devtools connection closed before replying.")); - }; - }); -} - -function buildManagedSwitchExpression(options?: { - force?: boolean; - timeoutMs?: number; -}): string { - const force = options?.force === true; - const timeoutMs = options?.timeoutMs ?? DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS; - - return `(async () => { - ${buildCodexDesktopGuardExpression()} - const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; - const force = ${JSON.stringify(force)}; - const timeoutMs = ${JSON.stringify(timeoutMs)}; - const fallbackPollIntervalMs = 2000; - const rpcTimeoutMs = 5000; - - const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); - const toError = (value, fallback) => { - if (value instanceof Error) { - return value; - } - - const message = - typeof value === "string" - ? value - : isRecord(value) && typeof value.message === "string" - ? value.message - : fallback; - return new Error(message); - }; - - const postMessage = async (message) => { - if (!window.electronBridge || typeof window.electronBridge.sendMessageFromView !== "function") { - throw new Error("Codex Desktop bridge is unavailable."); - } - - await window.electronBridge.sendMessageFromView(message); - }; - - const restart = async () => { - await postMessage({ - type: "codex-app-server-restart", - hostId, - }); - }; - - const pendingResponses = new Map(); - let nextRequestId = 1; - - const onMessage = (event) => { - const data = event?.data; - if (!isRecord(data) || data.type !== "mcp-response" || !isRecord(data.message)) { - return; - } - - const responseId = - typeof data.message.id === "string" || typeof data.message.id === "number" - ? String(data.message.id) - : null; - if (!responseId) { - return; - } - - const pending = pendingResponses.get(responseId); - if (!pending) { - return; - } - - pendingResponses.delete(responseId); - window.clearTimeout(pending.timeoutHandle); - - if (isRecord(data.message.error)) { - pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); - return; - } - - pending.resolve(data.message.result); - }; - - window.addEventListener("message", onMessage); - - const sendRpcRequest = async (method, params = {}) => { - const requestId = "codexm-switch-" + String(nextRequestId++); - - return await new Promise((resolve, reject) => { - const timeoutHandle = window.setTimeout(() => { - pendingResponses.delete(requestId); - reject(new Error("Timed out waiting for Codex Desktop bridge response.")); - }, rpcTimeoutMs); - - pendingResponses.set(requestId, { - resolve, - reject, - timeoutHandle, - }); - - void postMessage({ - type: "mcp-request", - hostId, - request: { - id: requestId, - method, - params, - }, - }).catch((error) => { - pendingResponses.delete(requestId); - window.clearTimeout(timeoutHandle); - reject(toError(error, "Failed to send Codex Desktop bridge request.")); - }); - }); - }; - - const listLoadedThreadIds = async () => { - const threadIds = []; - let cursor = null; - - while (true) { - const result = await sendRpcRequest( - "thread/loaded/list", - cursor ? { cursor } : {}, - ); - - const data = Array.isArray(result?.data) ? result.data : []; - for (const threadId of data) { - if (typeof threadId === "string" && threadId) { - threadIds.push(threadId); - } - } - - cursor = typeof result?.nextCursor === "string" && result.nextCursor ? result.nextCursor : null; - if (!cursor) { - return threadIds; - } - } - }; - - const collectActiveThreadIds = async () => { - const loadedThreadIds = await listLoadedThreadIds(); - const activeThreadIds = []; - - for (const threadId of loadedThreadIds) { - try { - const result = await sendRpcRequest("thread/read", { threadId }); - const thread = isRecord(result?.thread) ? result.thread : null; - const status = isRecord(thread?.status) ? thread.status : null; - - if (status?.type === "active") { - activeThreadIds.push(threadId); - } - } catch (error) { - const message = toError(error, "Failed to read thread state.").message; - if (!message.includes("notLoaded")) { - throw error; - } - } - } - - return activeThreadIds; - }; - - if (force) { - try { - await restart(); - return { mode: "force" }; - } finally { - window.removeEventListener("message", onMessage); - } - } - - try { - let activeThreadIds = await collectActiveThreadIds(); - if (activeThreadIds.length === 0) { - await restart(); - return { mode: "immediate" }; - } - - await new Promise((resolve, reject) => { - let settled = false; - let fallbackHandle = null; - let checking = false; - - const cleanup = () => { - window.clearTimeout(timeoutHandle); - if (fallbackHandle !== null) { - window.clearInterval(fallbackHandle); - } - }; - - const finishWithError = (error) => { - if (settled) { - return; - } - - settled = true; - cleanup(); - reject(error); - }; - - const finishWithRestart = async () => { - if (settled) { - return; - } - - settled = true; - cleanup(); - - try { - await restart(); - resolve(undefined); - } catch (error) { - reject(toError(error, "Failed to restart the Codex app server.")); - } - }; - - const checkThreads = async () => { - if (settled || checking) { - return; - } - - checking = true; - - try { - activeThreadIds = await collectActiveThreadIds(); - if (activeThreadIds.length === 0) { - await finishWithRestart(); - } - } catch (error) { - finishWithError(toError(error, "Failed to refresh active thread state.")); - } finally { - checking = false; - } - }; - - const timeoutHandle = window.setTimeout(() => { - finishWithError( - new Error("Timed out waiting for the current Codex thread to finish."), - ); - }, timeoutMs); - - fallbackHandle = window.setInterval(() => { - void checkThreads(); - }, fallbackPollIntervalMs); - - void checkThreads(); - }); - - return { mode: "waited" }; - } finally { - for (const pending of pendingResponses.values()) { - window.clearTimeout(pending.timeoutHandle); - } - pendingResponses.clear(); - window.removeEventListener("message", onMessage); - } -})()`; -} - export function createCodexDesktopLauncher(options: { execFileImpl?: ExecFileLike; statePath?: string; diff --git a/src/codex-desktop-process.ts b/src/codex-desktop-process.ts new file mode 100644 index 0000000..6d79b06 --- /dev/null +++ b/src/codex-desktop-process.ts @@ -0,0 +1,102 @@ +import { spawn as spawnCallback } from "node:child_process"; + +import type { + ExecFileLike, + ManagedCodexDesktopState, + RunningCodexDesktop, +} from "./codex-desktop-types.js"; +import { CODEX_BINARY_SUFFIX } from "./codex-desktop-shared.js"; + +export type LaunchProcessLike = (options: { + appPath: string; + binaryPath: string; + args: readonly string[]; +}) => Promise; + +export async function pathExistsViaStat( + execFileImpl: ExecFileLike, + path: string, +): Promise { + try { + await execFileImpl("stat", ["-f", "%N", path]); + return true; + } catch { + return false; + } +} + +export async function readProcessParentAndCommand( + execFileImpl: ExecFileLike, + pid: number, +): Promise<{ ppid: number; command: string } | null> { + try { + const { stdout } = await execFileImpl("ps", ["-o", "ppid=,command=", "-p", String(pid)]); + const line = stdout + .split("\n") + .map((entry) => entry.trim()) + .find((entry) => entry !== ""); + if (!line) { + return null; + } + + const match = line.match(/^(\d+)\s+(.+)$/); + if (!match) { + return null; + } + + return { + ppid: Number(match[1]), + command: match[2], + }; + } catch { + return null; + } +} + +export async function launchManagedDesktopProcess(options: { + appPath: string; + binaryPath: string; + args: readonly string[]; +}): Promise { + await new Promise((resolve, reject) => { + const child = spawnCallback(options.binaryPath, [...options.args], { + cwd: options.appPath, + detached: true, + stdio: "ignore", + }); + + let settled = false; + + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + + child.once("error", (error) => { + settle(() => reject(error)); + }); + + child.once("spawn", () => { + child.unref(); + settle(resolve); + }); + }); +} + +export function isManagedDesktopProcess( + runningApps: RunningCodexDesktop[], + state: ManagedCodexDesktopState, +): boolean { + const expectedBinaryPath = `${state.app_path}${CODEX_BINARY_SUFFIX}`; + const expectedPort = `--remote-debugging-port=${state.remote_debugging_port}`; + + return runningApps.some( + (entry) => + entry.pid === state.pid && + entry.command.includes(expectedBinaryPath) && + entry.command.includes(expectedPort), + ); +} diff --git a/src/codex-desktop-runtime.ts b/src/codex-desktop-runtime.ts new file mode 100644 index 0000000..037b6c5 --- /dev/null +++ b/src/codex-desktop-runtime.ts @@ -0,0 +1,882 @@ +import type { + ManagedQuotaSignal, + ManagedWatchActivitySignal, + RuntimeAccountSnapshot, + RuntimeQuotaSnapshot, +} from "./codex-desktop-types.js"; +import { + buildCodexDesktopGuardExpression, + CODEXM_WATCH_CONSOLE_PREFIX, + CODEX_LOCAL_HOST_ID, + DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, + DEVTOOLS_REQUEST_TIMEOUT_MS, + normalizeBodySnippet, + isRecord, +} from "./codex-desktop-shared.js"; + +interface ProbeConsolePayload { + kind?: unknown; + message?: unknown; + event?: unknown; + direction?: unknown; +} + +interface BridgeProbePayload { + kind: "bridge"; + direction: string | null; + event: Record; +} + +export function buildManagedWatchProbeExpression(): string { + return `(() => { + ${buildCodexDesktopGuardExpression()} + const prefix = ${JSON.stringify(CODEXM_WATCH_CONSOLE_PREFIX)}; + const globalState = window.__codexmWatchState ?? { installed: false }; + + if (globalState.installed) { + return { installed: true }; + } + + globalState.installed = true; + window.__codexmWatchState = globalState; + + const emitBridge = (direction, event) => { + if (!event || typeof event !== "object" || Array.isArray(event)) { + return; + } + const type = typeof event.type === "string" ? event.type : ""; + if (!type.startsWith("mcp-")) { + return; + } + console.debug(prefix + JSON.stringify({ kind: "bridge", direction, event })); + }; + window.addEventListener("codex-message-from-view", (event) => { + emitBridge("from_view", event.detail); + }); + window.addEventListener("message", (event) => { + emitBridge("for_view", event.data); + }); + + return { installed: true }; +})()`; +} + +export function buildManagedCurrentQuotaExpression(): string { + return `(async () => { + ${buildCodexDesktopGuardExpression()} + const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; + const rpcTimeoutMs = 5000; + + const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); + const toError = (value, fallback) => { + if (value instanceof Error) { + return value; + } + + const message = + typeof value === "string" + ? value + : isRecord(value) && typeof value.message === "string" + ? value.message + : fallback; + return new Error(message); + }; + + const postMessage = async (message) => { + if (!window.electronBridge || typeof window.electronBridge.sendMessageFromView !== "function") { + throw new Error("Codex Desktop bridge is unavailable."); + } + + await window.electronBridge.sendMessageFromView(message); + }; + + const pendingResponses = new Map(); + let nextRequestId = 1; + + const onMessage = (event) => { + const data = event?.data; + if (!isRecord(data) || data.type !== "mcp-response" || !isRecord(data.message)) { + return; + } + + const responseId = + typeof data.message.id === "string" || typeof data.message.id === "number" + ? String(data.message.id) + : null; + if (!responseId) { + return; + } + + const pending = pendingResponses.get(responseId); + if (!pending) { + return; + } + + pendingResponses.delete(responseId); + window.clearTimeout(pending.timeoutHandle); + + if (isRecord(data.message.error)) { + pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); + return; + } + + pending.resolve(data.message.result); + }; + + window.addEventListener("message", onMessage); + + const sendRpcRequest = async (method, params = {}) => { + const requestId = "codexm-current-" + String(nextRequestId++); + + return await new Promise((resolve, reject) => { + const timeoutHandle = window.setTimeout(() => { + pendingResponses.delete(requestId); + reject(new Error("Timed out waiting for Codex Desktop bridge response.")); + }, rpcTimeoutMs); + + pendingResponses.set(requestId, { + resolve, + reject, + timeoutHandle, + }); + + void postMessage({ + type: "mcp-request", + hostId, + request: { + id: requestId, + method, + params, + }, + }).catch((error) => { + pendingResponses.delete(requestId); + window.clearTimeout(timeoutHandle); + reject(toError(error, "Failed to send Codex Desktop bridge request.")); + }); + }); + }; + + try { + const result = await sendRpcRequest("account/rateLimits/read", {}); + return isRecord(result) ? result : null; + } finally { + for (const pending of pendingResponses.values()) { + window.clearTimeout(pending.timeoutHandle); + } + pendingResponses.clear(); + window.removeEventListener("message", onMessage); + } +})()`; +} + +export function buildManagedCurrentAccountExpression(): string { + return `(async () => { + ${buildCodexDesktopGuardExpression()} + const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; + const rpcTimeoutMs = ${DEVTOOLS_REQUEST_TIMEOUT_MS}; + const pendingResponses = new Map(); + let nextRequestId = 1; + + const toError = (value, fallback) => { + if (value instanceof Error) { + return value; + } + if (value && typeof value === "object" && typeof value.message === "string") { + return new Error(value.message); + } + if (typeof value === "string" && value.trim() !== "") { + return new Error(value); + } + return new Error(fallback); + }; + + const postMessage = async (message) => { + if ( + typeof window === "undefined" || + !window.electronBridge || + typeof window.electronBridge.sendMessageFromView !== "function" + ) { + throw new Error("Codex Desktop bridge is unavailable."); + } + + return await window.electronBridge.sendMessageFromView(message); + }; + + const onMessage = (event) => { + const data = event && typeof event === "object" ? event.data : null; + if (!data || typeof data !== "object") { + return; + } + + if (data.hostId !== hostId) { + return; + } + + if (data.type === "mcp-response" && data.message && typeof data.message.id === "string") { + const pending = pendingResponses.get(data.message.id); + if (!pending) { + return; + } + + pendingResponses.delete(data.message.id); + window.clearTimeout(pending.timeoutHandle); + + if (data.message.error) { + pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); + return; + } + + pending.resolve(data.message.result); + } + }; + + window.addEventListener("message", onMessage); + + const sendRpcRequest = async (method, params = {}) => { + const requestId = "codexm-current-account-" + String(nextRequestId++); + + return await new Promise((resolve, reject) => { + const timeoutHandle = window.setTimeout(() => { + pendingResponses.delete(requestId); + reject(new Error("Timed out waiting for Codex Desktop bridge response.")); + }, rpcTimeoutMs); + + pendingResponses.set(requestId, { + resolve, + reject, + timeoutHandle, + }); + + void postMessage({ + type: "mcp-request", + hostId, + request: { + id: requestId, + method, + params, + }, + }).catch((error) => { + pendingResponses.delete(requestId); + window.clearTimeout(timeoutHandle); + reject(toError(error, "Failed to send Codex Desktop bridge request.")); + }); + }); + }; + + try { + const result = await sendRpcRequest("account/read", { refreshToken: false }); + return result && typeof result === "object" ? result : null; + } finally { + for (const pending of pendingResponses.values()) { + window.clearTimeout(pending.timeoutHandle); + } + pendingResponses.clear(); + window.removeEventListener("message", onMessage); + } +})()`; +} + +function epochSecondsToIsoString(value: unknown): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + + return new Date(value * 1000).toISOString(); +} + +function normalizeManagedQuotaWindow( + value: unknown, + fallbackWindowSeconds: number, +): RuntimeQuotaSnapshot["five_hour"] { + if (!isRecord(value) || typeof value.usedPercent !== "number") { + return null; + } + + const windowDurationMins = + typeof value.windowDurationMins === "number" && Number.isFinite(value.windowDurationMins) + ? value.windowDurationMins + : null; + + return { + used_percent: value.usedPercent, + window_seconds: windowDurationMins === null ? fallbackWindowSeconds : windowDurationMins * 60, + reset_at: epochSecondsToIsoString(value.resetsAt), + }; +} + +export function normalizeRuntimeQuotaSnapshot(value: unknown): RuntimeQuotaSnapshot | null { + if (!isRecord(value)) { + return null; + } + + const rateLimits = isRecord(value.rateLimits) ? value.rateLimits : null; + if (!rateLimits) { + return null; + } + + const credits = isRecord(rateLimits.credits) ? rateLimits.credits : null; + const balanceValue = credits?.balance; + const creditsBalance = + typeof balanceValue === "string" && balanceValue.trim() !== "" + ? Number(balanceValue) + : typeof balanceValue === "number" + ? balanceValue + : null; + + return { + plan_type: typeof rateLimits.planType === "string" ? rateLimits.planType : null, + credits_balance: Number.isFinite(creditsBalance) ? creditsBalance : null, + unlimited: credits?.unlimited === true, + five_hour: normalizeManagedQuotaWindow(rateLimits.primary ?? rateLimits.primaryWindow, 18_000), + one_week: normalizeManagedQuotaWindow( + rateLimits.secondary ?? rateLimits.secondaryWindow, + 604_800, + ), + fetched_at: new Date().toISOString(), + }; +} + +export function normalizeRuntimeAccountSnapshot(value: unknown): RuntimeAccountSnapshot | null { + if (!isRecord(value)) { + return null; + } + + const account = isRecord(value.account) ? value.account : null; + const accountType = account?.type; + const authMode = + accountType === "apiKey" + ? "apikey" + : accountType === "chatgpt" + ? "chatgpt" + : null; + + return { + auth_mode: authMode, + email: typeof account?.email === "string" ? account.email : null, + plan_type: typeof account?.planType === "string" ? account.planType : null, + requires_openai_auth: + typeof value.requiresOpenaiAuth === "boolean" ? value.requiresOpenaiAuth : null, + }; +} + +export function extractRuntimeConsoleText(payload: Record): string | null { + const args = Array.isArray(payload.args) ? payload.args : []; + const parts = args + .map((arg) => { + if (!isRecord(arg)) { + return null; + } + + if (typeof arg.value === "string") { + return arg.value; + } + if (typeof arg.unserializableValue === "string") { + return arg.unserializableValue; + } + if (typeof arg.description === "string") { + return arg.description; + } + + return null; + }) + .filter((value): value is string => typeof value === "string" && value.trim() !== ""); + + if (parts.length === 0) { + return null; + } + + return parts.join(" "); +} + +export function extractProbeConsolePayload(message: string | null): ProbeConsolePayload | null { + if (!message || !message.startsWith(CODEXM_WATCH_CONSOLE_PREFIX)) { + return null; + } + + const rawPayload = message.slice(CODEXM_WATCH_CONSOLE_PREFIX.length); + try { + const parsed = JSON.parse(rawPayload) as ProbeConsolePayload; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function normalizeBridgeProbePayload(payload: ProbeConsolePayload | null): BridgeProbePayload | null { + if (payload?.kind !== "bridge" || !isRecord(payload.event)) { + return null; + } + + return { + kind: "bridge", + direction: typeof payload.direction === "string" ? payload.direction : null, + event: payload.event, + }; +} + +export function formatBridgeDebugLine(payload: BridgeProbePayload): string { + return JSON.stringify({ + method: "Bridge.message", + params: { + direction: payload.direction, + event: payload.event, + }, + }); +} + +function stringifySnippet(value: unknown): string | null { + try { + return normalizeBodySnippet(JSON.stringify(value)); + } catch { + return null; + } +} + +function hasStructuredQuotaError(value: unknown, depth = 0): boolean { + if (depth > 8) { + return false; + } + + if (Array.isArray(value)) { + return value.some((entry) => hasStructuredQuotaError(entry, depth + 1)); + } + + if (!isRecord(value)) { + return false; + } + + if (value.codexErrorInfo === "usageLimitExceeded") { + return true; + } + + const exactErrorCodeCandidates = [ + value.code, + value.errorCode, + value.error_code, + value.type, + ]; + if (exactErrorCodeCandidates.some((entry) => entry === "insufficient_quota")) { + return true; + } + + return Object.values(value).some((entry) => hasStructuredQuotaError(entry, depth + 1)); +} + +function hasExhaustedRateLimit(value: unknown, depth = 0): boolean { + if (depth > 8) { + return false; + } + + if (Array.isArray(value)) { + return value.some((entry) => hasExhaustedRateLimit(entry, depth + 1)); + } + + if (!isRecord(value)) { + return false; + } + + const usedPercent = value.usedPercent ?? value.used_percent; + if (typeof usedPercent === "number" && usedPercent >= 100) { + return true; + } + + return Object.values(value).some((entry) => hasExhaustedRateLimit(entry, depth + 1)); +} + +function buildRpcQuotaSignal(options: { + event: Record; + requestId: string; + method: string | null; + reason: "rpc_response" | "rpc_notification"; + shouldAutoSwitch: boolean; + quota: RuntimeQuotaSnapshot | null; +}): ManagedQuotaSignal { + return { + requestId: options.requestId, + url: options.method ? `mcp:${options.method}` : "mcp", + status: null, + reason: options.reason, + bodySnippet: stringifySnippet(options.event), + shouldAutoSwitch: options.shouldAutoSwitch, + quota: options.quota, + }; +} + +export function extractRpcQuotaSignal( + payload: BridgeProbePayload | null, + rpcRequestMethods: Map, +): ManagedQuotaSignal | null { + if (!payload) { + return null; + } + + const event = payload.event; + const eventType = typeof event.type === "string" ? event.type : null; + if (!eventType?.startsWith("mcp-")) { + return null; + } + + if (eventType === "mcp-request") { + const request = isRecord(event.request) ? event.request : null; + const requestId = + typeof request?.id === "string" || typeof request?.id === "number" + ? String(request.id) + : null; + const method = typeof request?.method === "string" ? request.method : null; + if (requestId && method) { + rpcRequestMethods.set(requestId, method); + } + return null; + } + + if (eventType === "mcp-notification") { + const method = typeof event.method === "string" ? event.method : null; + if (method === "account/rateLimits/updated") { + return null; + } + if ( + method === "error" && hasStructuredQuotaError(event.params) + ) { + return buildRpcQuotaSignal({ + event, + requestId: `rpc:notification:${method ?? "unknown"}`, + method, + reason: "rpc_notification", + shouldAutoSwitch: true, + quota: null, + }); + } + return null; + } + + if (eventType !== "mcp-response") { + return null; + } + + const message = isRecord(event.message) ? event.message : null; + const responseId = + typeof message?.id === "string" || typeof message?.id === "number" + ? String(message.id) + : "unknown"; + const method = rpcRequestMethods.get(responseId) ?? null; + if (method === "account/rateLimits/read" && isRecord(message?.result)) { + if (responseId.startsWith("codexm-current-")) { + return null; + } + + return buildRpcQuotaSignal({ + event, + requestId: `rpc:${responseId}`, + method, + reason: "rpc_response", + shouldAutoSwitch: hasExhaustedRateLimit(message?.result), + quota: normalizeRuntimeQuotaSnapshot(message?.result), + }); + } + + if ( + hasStructuredQuotaError(message?.error) + ) { + return buildRpcQuotaSignal({ + event, + requestId: `rpc:${responseId}`, + method, + reason: "rpc_response", + shouldAutoSwitch: true, + quota: null, + }); + } + + return null; +} + +export function extractRpcActivitySignal( + payload: BridgeProbePayload | null, +): ManagedWatchActivitySignal | null { + if (!payload) { + return null; + } + + const event = payload.event; + const eventType = typeof event.type === "string" ? event.type : null; + if (eventType !== "mcp-notification") { + return null; + } + + const method = typeof event.method === "string" ? event.method : null; + if (method === "account/rateLimits/updated") { + return { + requestId: `rpc:notification:${method}`, + method, + reason: "quota_dirty", + bodySnippet: stringifySnippet(event), + }; + } + + if (method === "turn/completed") { + return { + requestId: `rpc:notification:${method}`, + method, + reason: "turn_completed", + bodySnippet: stringifySnippet(event), + }; + } + + return null; +} + +export function buildManagedSwitchExpression(options?: { + force?: boolean; + timeoutMs?: number; +}): string { + const force = options?.force === true; + const timeoutMs = options?.timeoutMs ?? DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS; + + return `(async () => { + ${buildCodexDesktopGuardExpression()} + const hostId = ${JSON.stringify(CODEX_LOCAL_HOST_ID)}; + const force = ${JSON.stringify(force)}; + const timeoutMs = ${JSON.stringify(timeoutMs)}; + const fallbackPollIntervalMs = 2000; + const rpcTimeoutMs = 5000; + + const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); + const toError = (value, fallback) => { + if (value instanceof Error) { + return value; + } + + const message = + typeof value === "string" + ? value + : isRecord(value) && typeof value.message === "string" + ? value.message + : fallback; + return new Error(message); + }; + + const postMessage = async (message) => { + if (!window.electronBridge || typeof window.electronBridge.sendMessageFromView !== "function") { + throw new Error("Codex Desktop bridge is unavailable."); + } + + await window.electronBridge.sendMessageFromView(message); + }; + + const restart = async () => { + await postMessage({ + type: "codex-app-server-restart", + hostId, + }); + }; + + const pendingResponses = new Map(); + let nextRequestId = 1; + + const onMessage = (event) => { + const data = event?.data; + if (!isRecord(data) || data.type !== "mcp-response" || !isRecord(data.message)) { + return; + } + + const responseId = + typeof data.message.id === "string" || typeof data.message.id === "number" + ? String(data.message.id) + : null; + if (!responseId) { + return; + } + + const pending = pendingResponses.get(responseId); + if (!pending) { + return; + } + + pendingResponses.delete(responseId); + window.clearTimeout(pending.timeoutHandle); + + if (isRecord(data.message.error)) { + pending.reject(toError(data.message.error, "Codex Desktop bridge request failed.")); + return; + } + + pending.resolve(data.message.result); + }; + + window.addEventListener("message", onMessage); + + const sendRpcRequest = async (method, params = {}) => { + const requestId = "codexm-switch-" + String(nextRequestId++); + + return await new Promise((resolve, reject) => { + const timeoutHandle = window.setTimeout(() => { + pendingResponses.delete(requestId); + reject(new Error("Timed out waiting for Codex Desktop bridge response.")); + }, rpcTimeoutMs); + + pendingResponses.set(requestId, { + resolve, + reject, + timeoutHandle, + }); + + void postMessage({ + type: "mcp-request", + hostId, + request: { + id: requestId, + method, + params, + }, + }).catch((error) => { + pendingResponses.delete(requestId); + window.clearTimeout(timeoutHandle); + reject(toError(error, "Failed to send Codex Desktop bridge request.")); + }); + }); + }; + + const listLoadedThreadIds = async () => { + const threadIds = []; + let cursor = null; + + while (true) { + const result = await sendRpcRequest( + "thread/loaded/list", + cursor ? { cursor } : {}, + ); + + const data = Array.isArray(result?.data) ? result.data : []; + for (const threadId of data) { + if (typeof threadId === "string" && threadId) { + threadIds.push(threadId); + } + } + + cursor = typeof result?.nextCursor === "string" && result.nextCursor ? result.nextCursor : null; + if (!cursor) { + return threadIds; + } + } + }; + + const collectActiveThreadIds = async () => { + const loadedThreadIds = await listLoadedThreadIds(); + const activeThreadIds = []; + + for (const threadId of loadedThreadIds) { + try { + const result = await sendRpcRequest("thread/read", { threadId }); + const thread = isRecord(result?.thread) ? result.thread : null; + const status = isRecord(thread?.status) ? thread.status : null; + + if (status?.type === "active") { + activeThreadIds.push(threadId); + } + } catch (error) { + const message = toError(error, "Failed to read thread state.").message; + if (!message.includes("notLoaded")) { + throw error; + } + } + } + + return activeThreadIds; + }; + + if (force) { + try { + await restart(); + return { mode: "force" }; + } finally { + window.removeEventListener("message", onMessage); + } + } + + try { + let activeThreadIds = await collectActiveThreadIds(); + if (activeThreadIds.length === 0) { + await restart(); + return { mode: "immediate" }; + } + + await new Promise((resolve, reject) => { + let settled = false; + let fallbackHandle = null; + let checking = false; + + const cleanup = () => { + window.clearTimeout(timeoutHandle); + if (fallbackHandle !== null) { + window.clearInterval(fallbackHandle); + } + }; + + const finishWithError = (error) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(error); + }; + + const finishWithRestart = async () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + + try { + await restart(); + resolve(undefined); + } catch (error) { + reject(toError(error, "Failed to restart the Codex app server.")); + } + }; + + const checkThreads = async () => { + if (settled || checking) { + return; + } + + checking = true; + + try { + activeThreadIds = await collectActiveThreadIds(); + if (activeThreadIds.length === 0) { + await finishWithRestart(); + } + } catch (error) { + finishWithError(toError(error, "Failed to refresh active thread state.")); + } finally { + checking = false; + } + }; + + const timeoutHandle = window.setTimeout(() => { + finishWithError( + new Error("Timed out waiting for the current Codex thread to finish."), + ); + }, timeoutMs); + + fallbackHandle = window.setInterval(() => { + void checkThreads(); + }, fallbackPollIntervalMs); + + void checkThreads(); + }); + + return { mode: "waited" }; + } finally { + for (const pending of pendingResponses.values()) { + window.clearTimeout(pending.timeoutHandle); + } + pendingResponses.clear(); + window.removeEventListener("message", onMessage); + } +})()`; +} diff --git a/src/codex-desktop-shared.ts b/src/codex-desktop-shared.ts new file mode 100644 index 0000000..edc286b --- /dev/null +++ b/src/codex-desktop-shared.ts @@ -0,0 +1,122 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const DEFAULT_CODEX_REMOTE_DEBUGGING_PORT = 39223; +export const DEFAULT_CODEX_DESKTOP_STATE_PATH = join( + homedir(), + ".codex-team", + "desktop-state.json", +); +export const CODEX_BINARY_SUFFIX = "/Contents/MacOS/Codex"; +export const CODEX_APP_NAME = "Codex"; +export const CODEX_LOCAL_HOST_ID = "local"; +export const DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS = 120_000; +export const CODEXM_WATCH_CONSOLE_PREFIX = "__codexm_watch__"; +export const DEVTOOLS_REQUEST_TIMEOUT_MS = 5_000; +export const DEVTOOLS_SWITCH_TIMEOUT_BUFFER_MS = 10_000; +export const DEFAULT_WATCH_RECONNECT_DELAY_MS = 1_000; +export const DEFAULT_WATCH_HEALTH_CHECK_INTERVAL_MS = 5_000; +export const DEFAULT_WATCH_HEALTH_CHECK_TIMEOUT_MS = 3_000; + +export function buildCodexDesktopGuardExpression(): string { + return ` + const expectedHref = ${JSON.stringify(`app://-/index.html?hostId=${CODEX_LOCAL_HOST_ID}`)}; + const actualHref = + typeof window !== "undefined" && + window.location && + typeof window.location.href === "string" + ? window.location.href + : null; + const hasBridge = + typeof window !== "undefined" && + !!window.electronBridge && + typeof window.electronBridge.sendMessageFromView === "function"; + + if (actualHref !== expectedHref || !hasBridge) { + throw new Error("Connected debug console target is not Codex Desktop."); + } +`; +} + +export const CODEX_APP_SERVER_RESTART_EXPRESSION = `(async () => {${buildCodexDesktopGuardExpression()} + await window.electronBridge.sendMessageFromView({ type: "codex-app-server-restart", hostId: "local" }); +})()`; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +export async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createAbortError(): Error { + const error = new Error("Managed Codex Desktop refresh was interrupted."); + error.name = "AbortError"; + return error; +} + +export async function waitForPromiseOrAbort( + promise: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) { + return await promise; + } + + if (signal.aborted) { + throw createAbortError(); + } + + return await new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + reject(createAbortError()); + }; + + const cleanup = () => { + signal.removeEventListener("abort", onAbort); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + + void promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +export function normalizeBodySnippet(body: string | null): string | null { + if (!body) { + return null; + } + + return body.slice(0, 2_000); +} + +export function toErrorMessage(value: unknown, fallback: string): Error { + if (value instanceof Error) { + return value; + } + + if (isRecord(value) && typeof value.message === "string") { + return new Error(value.message); + } + + if (typeof value === "string" && value.trim() !== "") { + return new Error(value); + } + + return new Error(fallback); +} diff --git a/src/codex-desktop-state.ts b/src/codex-desktop-state.ts new file mode 100644 index 0000000..7d80bc1 --- /dev/null +++ b/src/codex-desktop-state.ts @@ -0,0 +1,54 @@ +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { ManagedCodexDesktopState } from "./codex-desktop-types.js"; +import { isNonEmptyString, isRecord } from "./codex-desktop-shared.js"; + +export function parseManagedState(raw: string): ManagedCodexDesktopState | null { + if (raw.trim() === "") { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + + const pid = parsed.pid; + const appPath = parsed.app_path; + const remoteDebuggingPort = parsed.remote_debugging_port; + const managedByCodexm = parsed.managed_by_codexm; + const startedAt = parsed.started_at; + + if ( + typeof pid !== "number" || + !Number.isInteger(pid) || + pid <= 0 || + !isNonEmptyString(appPath) || + typeof remoteDebuggingPort !== "number" || + !Number.isInteger(remoteDebuggingPort) || + remoteDebuggingPort <= 0 || + managedByCodexm !== true || + !isNonEmptyString(startedAt) + ) { + return null; + } + + return { + pid, + app_path: appPath, + remote_debugging_port: remoteDebuggingPort, + managed_by_codexm: true, + started_at: startedAt, + }; +} + +export async function ensureStateDirectory(statePath: string): Promise { + await mkdir(dirname(statePath), { recursive: true, mode: 0o700 }); +} diff --git a/src/codex-desktop-types.ts b/src/codex-desktop-types.ts new file mode 100644 index 0000000..4ae6014 --- /dev/null +++ b/src/codex-desktop-types.ts @@ -0,0 +1,108 @@ +export interface ExecFileLike { + ( + file: string, + args?: readonly string[], + ): Promise<{ stdout: string; stderr: string }>; +} + +export interface RunningCodexDesktop { + pid: number; + command: string; +} + +export interface ManagedCodexDesktopState { + pid: number; + app_path: string; + remote_debugging_port: number; + managed_by_codexm: true; + started_at: string; +} + +export interface ManagedQuotaSignal { + requestId: string; + url: string; + status: number | null; + reason: "rpc_response" | "rpc_notification"; + bodySnippet: string | null; + shouldAutoSwitch: boolean; + quota: RuntimeQuotaSnapshot | null; +} + +export interface ManagedWatchActivitySignal { + requestId: string; + method: string; + reason: "quota_dirty" | "turn_completed"; + bodySnippet: string | null; +} + +export interface ManagedWatchStatusEvent { + type: "disconnected" | "reconnected"; + attempt: number; + error: string | null; +} + +export interface RuntimeQuotaSnapshot { + plan_type: string | null; + credits_balance: number | null; + unlimited: boolean; + five_hour: { + used_percent: number; + window_seconds: number; + reset_at: string | null; + } | null; + one_week: { + used_percent: number; + window_seconds: number; + reset_at: string | null; + } | null; + fetched_at: string; +} + +export interface RuntimeAccountSnapshot { + auth_mode: string | null; + email: string | null; + plan_type: string | null; + requires_openai_auth: boolean | null; +} + +export type RuntimeReadSource = "desktop" | "direct"; + +export interface RuntimeReadResult { + snapshot: TSnapshot; + source: RuntimeReadSource; +} + +export type ManagedCurrentQuotaSnapshot = RuntimeQuotaSnapshot; +export type ManagedCurrentAccountSnapshot = RuntimeAccountSnapshot; + +export interface CodexDesktopLauncher { + findInstalledApp(): Promise; + listRunningApps(): Promise; + isRunningInsideDesktopShell(): Promise; + quitRunningApps(options?: { force?: boolean }): Promise; + launch(appPath: string): Promise; + readManagedState(): Promise; + writeManagedState(state: ManagedCodexDesktopState): Promise; + clearManagedState(): Promise; + isManagedDesktopRunning(): Promise; + readDirectRuntimeAccount(): Promise; + readDirectRuntimeQuota(): Promise; + readCurrentRuntimeAccountResult(): Promise | null>; + readCurrentRuntimeQuotaResult(): Promise | null>; + readCurrentRuntimeAccount(): Promise; + readCurrentRuntimeQuota(): Promise; + readManagedCurrentAccount(): Promise; + readManagedCurrentQuota(): Promise; + applyManagedSwitch(options?: { + force?: boolean; + timeoutMs?: number; + signal?: AbortSignal; + }): Promise; + watchManagedQuotaSignals(options?: { + signal?: AbortSignal; + debugLogger?: (line: string) => void; + onQuotaSignal?: (signal: ManagedQuotaSignal) => Promise | void; + onActivitySignal?: (signal: ManagedWatchActivitySignal) => Promise | void; + onStatus?: (event: ManagedWatchStatusEvent) => Promise | void; + }): Promise; +} From 8c951bc2977ead529eacc4da4007bbf0e082f086 Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 19:36:53 +0800 Subject: [PATCH 4/6] refactor(accounts): split account store repository helpers --- src/account-store-config.ts | 56 ++++ src/account-store-repository.ts | 227 ++++++++++++++ src/account-store-storage.ts | 92 ++++++ src/account-store-types.ts | 82 +++++ src/account-store.ts | 540 +++++--------------------------- 5 files changed, 543 insertions(+), 454 deletions(-) create mode 100644 src/account-store-config.ts create mode 100644 src/account-store-repository.ts create mode 100644 src/account-store-storage.ts create mode 100644 src/account-store-types.ts diff --git a/src/account-store-config.ts b/src/account-store-config.ts new file mode 100644 index 0000000..d0e5e8b --- /dev/null +++ b/src/account-store-config.ts @@ -0,0 +1,56 @@ +import type { AuthSnapshot } from "./auth-snapshot.js"; + +export function validateConfigSnapshot( + name: string, + snapshot: AuthSnapshot, + rawConfig: string | null, +): void { + if (snapshot.auth_mode !== "apikey") { + return; + } + + if (!rawConfig) { + throw new Error(`Current ~/.codex/config.toml is required to save apikey account "${name}".`); + } + + if (!/^\s*model_provider\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { + throw new Error(`Current ~/.codex/config.toml is missing model_provider for apikey account "${name}".`); + } + + if (!/^\s*base_url\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { + throw new Error(`Current ~/.codex/config.toml is missing base_url for apikey account "${name}".`); + } +} + +export function sanitizeConfigForAccountAuth(rawConfig: string): string { + const lines = rawConfig.split(/\r?\n/u); + const result: string[] = []; + let skippingProviderSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + skippingProviderSection = /^\[model_providers\.[^\]]+\]$/u.test(trimmed); + if (skippingProviderSection) { + continue; + } + } + + if (skippingProviderSection) { + continue; + } + + if (/^\s*model_provider\s*=/u.test(line)) { + continue; + } + + if (/^\s*preferred_auth_method\s*=\s*["']apikey["']\s*$/u.test(line)) { + continue; + } + + result.push(line); + } + + return `${result.join("\n").replace(/\n{3,}/gu, "\n\n").trimEnd()}\n`; +} diff --git a/src/account-store-repository.ts b/src/account-store-repository.ts new file mode 100644 index 0000000..c4b2409 --- /dev/null +++ b/src/account-store-repository.ts @@ -0,0 +1,227 @@ +import { join } from "node:path"; + +import { + AuthSnapshot, + SnapshotMeta, + createSnapshotMeta, + getMetaIdentity, + getSnapshotAccountId, + getSnapshotIdentity, + getSnapshotUserId, + isSupportedChatGPTAuthMode, + parseSnapshotMeta, + readAuthSnapshotFile, +} from "./auth-snapshot.js"; +import type { ManagedAccount, StorePaths, StoreState } from "./account-store-types.js"; +import { + DIRECTORY_MODE, + FILE_MODE, + SCHEMA_VERSION, + atomicWriteFile, + ensureAccountName, + ensureDirectory, + pathExists, + readJsonFile, + stringifyJson, +} from "./account-store-storage.js"; + +function canAutoMigrateLegacyChatGPTMeta( + meta: SnapshotMeta, + snapshot: AuthSnapshot, +): boolean { + if (!isSupportedChatGPTAuthMode(meta.auth_mode) || !isSupportedChatGPTAuthMode(snapshot.auth_mode)) { + return false; + } + + if (typeof meta.user_id === "string" && meta.user_id.trim() !== "") { + return false; + } + + const snapshotUserId = getSnapshotUserId(snapshot); + if (!snapshotUserId) { + return false; + } + + return meta.account_id === getSnapshotAccountId(snapshot); +} + +export class AccountStoreRepository { + readonly paths: StorePaths; + + constructor(paths: StorePaths) { + this.paths = paths; + } + + accountDirectory(name: string): string { + ensureAccountName(name); + return join(this.paths.accountsDir, name); + } + + accountAuthPath(name: string): string { + return join(this.accountDirectory(name), "auth.json"); + } + + accountMetaPath(name: string): string { + return join(this.accountDirectory(name), "meta.json"); + } + + accountConfigPath(name: string): string { + return join(this.accountDirectory(name), "config.toml"); + } + + async writeAccountAuthSnapshot(name: string, snapshot: AuthSnapshot): Promise { + await atomicWriteFile( + this.accountAuthPath(name), + stringifyJson(snapshot), + ); + } + + async writeAccountMeta(name: string, meta: SnapshotMeta): Promise { + await atomicWriteFile(this.accountMetaPath(name), stringifyJson(meta)); + } + + async ensureEmptyAccountConfigSnapshot(name: string): Promise { + const configPath = this.accountConfigPath(name); + await atomicWriteFile(configPath, ""); + return configPath; + } + + async syncCurrentAuthIfMatching(snapshot: AuthSnapshot): Promise { + if (!(await pathExists(this.paths.currentAuthPath))) { + return; + } + + try { + const currentSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); + if (getSnapshotIdentity(currentSnapshot) !== getSnapshotIdentity(snapshot)) { + return; + } + + await atomicWriteFile(this.paths.currentAuthPath, stringifyJson(snapshot)); + } catch { + // Ignore sync failures here; the stored snapshot is already updated. + } + } + + async ensureLayout(): Promise { + await ensureDirectory(this.paths.codexTeamDir, DIRECTORY_MODE); + await ensureDirectory(this.paths.accountsDir, DIRECTORY_MODE); + await ensureDirectory(this.paths.backupsDir, DIRECTORY_MODE); + } + + async readState(): Promise { + if (!(await pathExists(this.paths.statePath))) { + return { + schema_version: SCHEMA_VERSION, + last_switched_account: null, + last_backup_path: null, + }; + } + + const raw = await readJsonFile(this.paths.statePath); + const parsed = JSON.parse(raw) as Partial; + + return { + schema_version: parsed.schema_version ?? SCHEMA_VERSION, + last_switched_account: parsed.last_switched_account ?? null, + last_backup_path: parsed.last_backup_path ?? null, + }; + } + + async writeState(state: StoreState): Promise { + await this.ensureLayout(); + await atomicWriteFile(this.paths.statePath, stringifyJson(state)); + } + + async readManagedAccount(name: string): Promise { + const metaPath = this.accountMetaPath(name); + const authPath = this.accountAuthPath(name); + const [rawMeta, snapshot] = await Promise.all([ + readJsonFile(metaPath), + readAuthSnapshotFile(authPath), + ]); + let meta = parseSnapshotMeta(rawMeta); + + if (meta.name !== name) { + throw new Error(`Account metadata name mismatch for "${name}".`); + } + + const snapshotIdentity = getSnapshotIdentity(snapshot); + if (getMetaIdentity(meta) !== snapshotIdentity) { + if (canAutoMigrateLegacyChatGPTMeta(meta, snapshot)) { + meta = { + ...meta, + account_id: getSnapshotAccountId(snapshot), + user_id: getSnapshotUserId(snapshot), + }; + await this.writeAccountMeta(name, meta); + } else { + throw new Error(`Account metadata account_id mismatch for "${name}".`); + } + } + + if (getMetaIdentity(meta) !== snapshotIdentity) { + throw new Error(`Account metadata account_id mismatch for "${name}".`); + } + + return { + ...meta, + identity: getMetaIdentity(meta), + authPath, + metaPath, + configPath: (await pathExists(this.accountConfigPath(name))) ? this.accountConfigPath(name) : null, + duplicateAccountId: false, + }; + } + + async listAccounts(): Promise<{ accounts: ManagedAccount[]; warnings: string[] }> { + await this.ensureLayout(); + + const { readdir } = await import("node:fs/promises"); + const entries = await readdir(this.paths.accountsDir, { withFileTypes: true }); + const accounts: ManagedAccount[] = []; + const warnings: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + try { + accounts.push(await this.readManagedAccount(entry.name)); + } catch (error) { + warnings.push( + `Account "${entry.name}" is invalid: ${(error as Error).message}`, + ); + } + } + + const counts = new Map(); + for (const account of accounts) { + counts.set(account.identity, (counts.get(account.identity) ?? 0) + 1); + } + + accounts.sort((left, right) => left.name.localeCompare(right.name)); + + return { + accounts: accounts.map((account) => ({ + ...account, + duplicateAccountId: (counts.get(account.identity) ?? 0) > 1, + })), + warnings, + }; + } + + async readCurrentStatusAccounts() { + return await this.listAccounts(); + } + + createSnapshotMeta( + name: string, + snapshot: AuthSnapshot, + now: Date, + createdAt?: string | null, + ) { + return createSnapshotMeta(name, snapshot, now, createdAt ?? undefined); + } +} diff --git a/src/account-store-storage.ts b/src/account-store-storage.ts new file mode 100644 index 0000000..bd3a79b --- /dev/null +++ b/src/account-store-storage.ts @@ -0,0 +1,92 @@ +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; + +import type { StorePaths } from "./account-store-types.js"; + +export const DIRECTORY_MODE = 0o700; +export const FILE_MODE = 0o600; +export const SCHEMA_VERSION = 1; +export const QUOTA_REFRESH_CONCURRENCY = 3; + +const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export function defaultPaths(homeDir = homedir()): StorePaths { + const codexDir = join(homeDir, ".codex"); + const codexTeamDir = join(homeDir, ".codex-team"); + + return { + homeDir, + codexDir, + codexTeamDir, + currentAuthPath: join(codexDir, "auth.json"), + currentConfigPath: join(codexDir, "config.toml"), + accountsDir: join(codexTeamDir, "accounts"), + backupsDir: join(codexTeamDir, "backups"), + statePath: join(codexTeamDir, "state.json"), + }; +} + +export async function chmodIfPossible(path: string, mode: number): Promise { + try { + await chmod(path, mode); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "ENOENT") { + throw error; + } + } +} + +export async function ensureDirectory(path: string, mode: number): Promise { + await mkdir(path, { recursive: true, mode }); + await chmodIfPossible(path, mode); +} + +export async function atomicWriteFile( + path: string, + content: string, + mode = FILE_MODE, +): Promise { + const directory = dirname(path); + const tempPath = join( + directory, + `.${basename(path)}.${process.pid}.${Date.now()}.tmp`, + ); + + await ensureDirectory(directory, DIRECTORY_MODE); + await writeFile(tempPath, content, { encoding: "utf8", mode }); + await chmodIfPossible(tempPath, mode); + await rename(tempPath, path); + await chmodIfPossible(path, mode); +} + +export function stringifyJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export function ensureAccountName(name: string): void { + if (!ACCOUNT_NAME_PATTERN.test(name)) { + throw new Error( + 'Account name must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ and cannot contain path separators.', + ); + } +} + +export async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return false; + } + + throw error; + } +} + +export async function readJsonFile(path: string): Promise { + return readFile(path, "utf8"); +} diff --git a/src/account-store-types.ts b/src/account-store-types.ts new file mode 100644 index 0000000..d750e58 --- /dev/null +++ b/src/account-store-types.ts @@ -0,0 +1,82 @@ +import type { QuotaSnapshot, QuotaStatus, QuotaWindowSnapshot, SnapshotMeta } from "./auth-snapshot.js"; + +export interface StorePaths { + homeDir: string; + codexDir: string; + codexTeamDir: string; + currentAuthPath: string; + currentConfigPath: string; + accountsDir: string; + backupsDir: string; + statePath: string; +} + +export interface StoreState { + schema_version: number; + last_switched_account: string | null; + last_backup_path: string | null; +} + +export interface ManagedAccount extends SnapshotMeta { + identity: string; + authPath: string; + metaPath: string; + configPath: string | null; + duplicateAccountId: boolean; +} + +export interface AccountQuotaSummary { + name: string; + account_id: string; + user_id: string | null; + identity: string; + plan_type: string | null; + credits_balance: number | null; + status: QuotaStatus; + fetched_at: string | null; + error_message: string | null; + unlimited: boolean; + five_hour: QuotaWindowSnapshot | null; + one_week: QuotaWindowSnapshot | null; +} + +export interface CurrentAccountStatus { + exists: boolean; + auth_mode: string | null; + account_id: string | null; + user_id: string | null; + identity: string | null; + matched_accounts: string[]; + managed: boolean; + duplicate_match: boolean; + warnings: string[]; +} + +export interface DoctorReport { + healthy: boolean; + warnings: string[]; + issues: string[]; + account_count: number; + invalid_accounts: string[]; + current_auth_present: boolean; +} + +export interface SwitchAccountResult { + account: ManagedAccount; + warnings: string[]; + backup_path: string | null; +} + +export interface UpdateAccountResult { + account: ManagedAccount; +} + +export interface RefreshQuotaResult { + account: ManagedAccount; + quota: QuotaSnapshot; +} + +export interface RefreshAllQuotasResult { + successes: AccountQuotaSummary[]; + failures: Array<{ name: string; error: string }>; +} diff --git a/src/account-store.ts b/src/account-store.ts index 28caff6..9045a1e 100644 --- a/src/account-store.ts +++ b/src/account-store.ts @@ -1,228 +1,68 @@ -import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { join } from "node:path"; import { - chmod, copyFile, - mkdir, - readdir, - readFile, rename, rm, stat, - writeFile, } from "node:fs/promises"; import { AuthSnapshot, QuotaSnapshot, - QuotaStatus, - QuotaWindowSnapshot, - SnapshotMeta, - createSnapshotMeta, - getMetaIdentity, getSnapshotAccountId, getSnapshotIdentity, getSnapshotUserId, - isSupportedChatGPTAuthMode, parseAuthSnapshot, parseSnapshotMeta, readAuthSnapshotFile, } from "./auth-snapshot.js"; +import { AccountStoreRepository } from "./account-store-repository.js"; +import { sanitizeConfigForAccountAuth, validateConfigSnapshot } from "./account-store-config.js"; +import { + DIRECTORY_MODE, + FILE_MODE, + QUOTA_REFRESH_CONCURRENCY, + SCHEMA_VERSION, + atomicWriteFile, + chmodIfPossible, + defaultPaths, + ensureAccountName, + ensureDirectory, + pathExists, + readJsonFile, + stringifyJson, +} from "./account-store-storage.js"; +import type { + AccountQuotaSummary, + CurrentAccountStatus, + DoctorReport, + ManagedAccount, + RefreshAllQuotasResult, + RefreshQuotaResult, + StorePaths, + UpdateAccountResult, + SwitchAccountResult, +} from "./account-store-types.js"; import { extractChatGPTAuth, fetchQuotaSnapshot, } from "./quota-client.js"; - -const DIRECTORY_MODE = 0o700; -const FILE_MODE = 0o600; -const SCHEMA_VERSION = 1; -const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -const QUOTA_REFRESH_CONCURRENCY = 3; - -export interface StorePaths { - homeDir: string; - codexDir: string; - codexTeamDir: string; - currentAuthPath: string; - currentConfigPath: string; - accountsDir: string; - backupsDir: string; - statePath: string; -} - -export interface StoreState { - schema_version: number; - last_switched_account: string | null; - last_backup_path: string | null; -} - -export interface ManagedAccount extends SnapshotMeta { - identity: string; - authPath: string; - metaPath: string; - configPath: string | null; - duplicateAccountId: boolean; -} - -export interface AccountQuotaSummary { - name: string; - account_id: string; - user_id: string | null; - identity: string; - plan_type: string | null; - credits_balance: number | null; - status: QuotaStatus; - fetched_at: string | null; - error_message: string | null; - unlimited: boolean; - five_hour: QuotaWindowSnapshot | null; - one_week: QuotaWindowSnapshot | null; -} - -export interface CurrentAccountStatus { - exists: boolean; - auth_mode: string | null; - account_id: string | null; - user_id: string | null; - identity: string | null; - matched_accounts: string[]; - managed: boolean; - duplicate_match: boolean; - warnings: string[]; -} - -export interface DoctorReport { - healthy: boolean; - warnings: string[]; - issues: string[]; - account_count: number; - invalid_accounts: string[]; - current_auth_present: boolean; -} - -export interface SwitchAccountResult { - account: ManagedAccount; - warnings: string[]; - backup_path: string | null; -} - -export interface UpdateAccountResult { - account: ManagedAccount; -} - -export interface RefreshQuotaResult { - account: ManagedAccount; - quota: QuotaSnapshot; -} - -export interface RefreshAllQuotasResult { - successes: AccountQuotaSummary[]; - failures: Array<{ name: string; error: string }>; -} - -function defaultPaths(homeDir = homedir()): StorePaths { - const codexDir = join(homeDir, ".codex"); - const codexTeamDir = join(homeDir, ".codex-team"); - - return { - homeDir, - codexDir, - codexTeamDir, - currentAuthPath: join(codexDir, "auth.json"), - currentConfigPath: join(codexDir, "config.toml"), - accountsDir: join(codexTeamDir, "accounts"), - backupsDir: join(codexTeamDir, "backups"), - statePath: join(codexTeamDir, "state.json"), - }; -} - -async function chmodIfPossible(path: string, mode: number): Promise { - try { - await chmod(path, mode); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== "ENOENT") { - throw error; - } - } -} - -async function ensureDirectory(path: string, mode: number): Promise { - await mkdir(path, { recursive: true, mode }); - await chmodIfPossible(path, mode); -} - -async function atomicWriteFile( - path: string, - content: string, - mode = FILE_MODE, -): Promise { - const directory = dirname(path); - const tempPath = join( - directory, - `.${basename(path)}.${process.pid}.${Date.now()}.tmp`, - ); - - await ensureDirectory(directory, DIRECTORY_MODE); - await writeFile(tempPath, content, { encoding: "utf8", mode }); - await chmodIfPossible(tempPath, mode); - await rename(tempPath, path); - await chmodIfPossible(path, mode); -} - -function stringifyJson(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n`; -} - -function ensureAccountName(name: string): void { - if (!ACCOUNT_NAME_PATTERN.test(name)) { - throw new Error( - 'Account name must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ and cannot contain path separators.', - ); - } -} - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === "ENOENT") { - return false; - } - - throw error; - } -} - -async function readJsonFile(path: string): Promise { - return readFile(path, "utf8"); -} - -function canAutoMigrateLegacyChatGPTMeta( - meta: SnapshotMeta, - snapshot: AuthSnapshot, -): boolean { - if (!isSupportedChatGPTAuthMode(meta.auth_mode) || !isSupportedChatGPTAuthMode(snapshot.auth_mode)) { - return false; - } - - if (typeof meta.user_id === "string" && meta.user_id.trim() !== "") { - return false; - } - - const snapshotUserId = getSnapshotUserId(snapshot); - if (!snapshotUserId) { - return false; - } - - return meta.account_id === getSnapshotAccountId(snapshot); -} +export type { + AccountQuotaSummary, + CurrentAccountStatus, + DoctorReport, + ManagedAccount, + RefreshAllQuotasResult, + RefreshQuotaResult, + StorePaths, + SwitchAccountResult, + UpdateAccountResult, +} from "./account-store-types.js"; export class AccountStore { readonly paths: StorePaths; readonly fetchImpl?: typeof fetch; + private readonly repository: AccountStoreRepository; constructor(paths?: Partial & { homeDir?: string; fetchImpl?: typeof fetch }) { const resolved = defaultPaths(paths?.homeDir); @@ -232,111 +72,7 @@ export class AccountStore { homeDir: paths?.homeDir ?? resolved.homeDir, }; this.fetchImpl = paths?.fetchImpl; - } - - private accountDirectory(name: string): string { - ensureAccountName(name); - return join(this.paths.accountsDir, name); - } - - private accountAuthPath(name: string): string { - return join(this.accountDirectory(name), "auth.json"); - } - - private accountMetaPath(name: string): string { - return join(this.accountDirectory(name), "meta.json"); - } - - private accountConfigPath(name: string): string { - return join(this.accountDirectory(name), "config.toml"); - } - - private async writeAccountAuthSnapshot( - name: string, - snapshot: AuthSnapshot, - ): Promise { - await atomicWriteFile( - this.accountAuthPath(name), - stringifyJson(snapshot), - ); - } - - private async writeAccountMeta(name: string, meta: SnapshotMeta): Promise { - await atomicWriteFile(this.accountMetaPath(name), stringifyJson(meta)); - } - - private validateConfigSnapshot(name: string, snapshot: AuthSnapshot, rawConfig: string | null): void { - if (snapshot.auth_mode !== "apikey") { - return; - } - - if (!rawConfig) { - throw new Error(`Current ~/.codex/config.toml is required to save apikey account "${name}".`); - } - - if (!/^\s*model_provider\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { - throw new Error(`Current ~/.codex/config.toml is missing model_provider for apikey account "${name}".`); - } - - if (!/^\s*base_url\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { - throw new Error(`Current ~/.codex/config.toml is missing base_url for apikey account "${name}".`); - } - } - - private sanitizeConfigForAccountAuth(rawConfig: string): string { - const lines = rawConfig.split(/\r?\n/u); - const result: string[] = []; - let skippingProviderSection = false; - - for (const line of lines) { - const trimmed = line.trim(); - - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - skippingProviderSection = /^\[model_providers\.[^\]]+\]$/u.test(trimmed); - if (skippingProviderSection) { - continue; - } - } - - if (skippingProviderSection) { - continue; - } - - if (/^\s*model_provider\s*=/u.test(line)) { - continue; - } - - if (/^\s*preferred_auth_method\s*=\s*["']apikey["']\s*$/u.test(line)) { - continue; - } - - result.push(line); - } - - return `${result.join("\n").replace(/\n{3,}/gu, "\n\n").trimEnd()}\n`; - } - - private async ensureEmptyAccountConfigSnapshot(name: string): Promise { - const configPath = this.accountConfigPath(name); - await atomicWriteFile(configPath, ""); - return configPath; - } - - private async syncCurrentAuthIfMatching(snapshot: AuthSnapshot): Promise { - if (!(await pathExists(this.paths.currentAuthPath))) { - return; - } - - try { - const currentSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); - if (getSnapshotIdentity(currentSnapshot) !== getSnapshotIdentity(snapshot)) { - return; - } - - await atomicWriteFile(this.paths.currentAuthPath, stringifyJson(snapshot)); - } catch { - // Ignore sync failures here; the stored snapshot is already updated. - } + this.repository = new AccountStoreRepository(this.paths); } private async quotaSummaryForAccount( @@ -368,112 +104,8 @@ export class AccountStore { }; } - private async ensureLayout(): Promise { - await ensureDirectory(this.paths.codexTeamDir, DIRECTORY_MODE); - await ensureDirectory(this.paths.accountsDir, DIRECTORY_MODE); - await ensureDirectory(this.paths.backupsDir, DIRECTORY_MODE); - } - - private async readState(): Promise { - if (!(await pathExists(this.paths.statePath))) { - return { - schema_version: SCHEMA_VERSION, - last_switched_account: null, - last_backup_path: null, - }; - } - - const raw = await readJsonFile(this.paths.statePath); - const parsed = JSON.parse(raw) as Partial; - - return { - schema_version: parsed.schema_version ?? SCHEMA_VERSION, - last_switched_account: parsed.last_switched_account ?? null, - last_backup_path: parsed.last_backup_path ?? null, - }; - } - - private async writeState(state: StoreState): Promise { - await this.ensureLayout(); - await atomicWriteFile(this.paths.statePath, stringifyJson(state)); - } - - private async readManagedAccount(name: string): Promise { - const metaPath = this.accountMetaPath(name); - const authPath = this.accountAuthPath(name); - const [rawMeta, snapshot] = await Promise.all([ - readJsonFile(metaPath), - readAuthSnapshotFile(authPath), - ]); - let meta = parseSnapshotMeta(rawMeta); - - if (meta.name !== name) { - throw new Error(`Account metadata name mismatch for "${name}".`); - } - - const snapshotIdentity = getSnapshotIdentity(snapshot); - if (getMetaIdentity(meta) !== snapshotIdentity) { - if (canAutoMigrateLegacyChatGPTMeta(meta, snapshot)) { - meta = { - ...meta, - account_id: getSnapshotAccountId(snapshot), - user_id: getSnapshotUserId(snapshot), - }; - await this.writeAccountMeta(name, meta); - } else { - throw new Error(`Account metadata account_id mismatch for "${name}".`); - } - } - - if (getMetaIdentity(meta) !== snapshotIdentity) { - throw new Error(`Account metadata account_id mismatch for "${name}".`); - } - - return { - ...meta, - identity: getMetaIdentity(meta), - authPath, - metaPath, - configPath: (await pathExists(this.accountConfigPath(name))) ? this.accountConfigPath(name) : null, - duplicateAccountId: false, - }; - } - async listAccounts(): Promise<{ accounts: ManagedAccount[]; warnings: string[] }> { - await this.ensureLayout(); - - const entries = await readdir(this.paths.accountsDir, { withFileTypes: true }); - const accounts: ManagedAccount[] = []; - const warnings: string[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - try { - accounts.push(await this.readManagedAccount(entry.name)); - } catch (error) { - warnings.push( - `Account "${entry.name}" is invalid: ${(error as Error).message}`, - ); - } - } - - const counts = new Map(); - for (const account of accounts) { - counts.set(account.identity, (counts.get(account.identity) ?? 0) + 1); - } - - accounts.sort((left, right) => left.name.localeCompare(right.name)); - - return { - accounts: accounts.map((account) => ({ - ...account, - duplicateAccountId: (counts.get(account.identity) ?? 0) > 1, - })), - warnings, - }; + return await this.repository.listAccounts(); } async getCurrentStatus(): Promise { @@ -516,7 +148,7 @@ export class AccountStore { async saveCurrentAccount(name: string, force = false): Promise { ensureAccountName(name); - await this.ensureLayout(); + await this.repository.ensureLayout(); if (!(await pathExists(this.paths.currentAuthPath))) { throw new Error("Current ~/.codex/auth.json does not exist."); @@ -528,10 +160,10 @@ export class AccountStore { (await pathExists(this.paths.currentConfigPath)) ? await readJsonFile(this.paths.currentConfigPath) : null; - const accountDir = this.accountDirectory(name); - const authPath = this.accountAuthPath(name); - const metaPath = this.accountMetaPath(name); - const configPath = this.accountConfigPath(name); + const accountDir = this.repository.accountDirectory(name); + const authPath = this.repository.accountAuthPath(name); + const metaPath = this.repository.accountMetaPath(name); + const configPath = this.repository.accountConfigPath(name); const identity = getSnapshotIdentity(snapshot); const accountExists = await pathExists(accountDir); const existingMeta = @@ -554,7 +186,7 @@ export class AccountStore { ); } - this.validateConfigSnapshot(name, snapshot, rawConfig); + validateConfigSnapshot(name, snapshot, rawConfig); await ensureDirectory(accountDir, DIRECTORY_MODE); await atomicWriteFile(authPath, `${rawSnapshot.trimEnd()}\n`); if (snapshot.auth_mode === "apikey" && rawConfig) { @@ -562,7 +194,7 @@ export class AccountStore { } else if (await pathExists(configPath)) { await rm(configPath, { force: true }); } - const meta = createSnapshotMeta( + const meta = this.repository.createSnapshotMeta( name, snapshot, new Date(), @@ -575,7 +207,7 @@ export class AccountStore { stringifyJson(meta), ); - return this.readManagedAccount(name); + return await this.repository.readManagedAccount(name); } async addAccountSnapshot( @@ -587,15 +219,15 @@ export class AccountStore { } = {}, ): Promise { ensureAccountName(name); - await this.ensureLayout(); + await this.repository.ensureLayout(); const normalizedSnapshot = parseAuthSnapshot(JSON.stringify(snapshot)); const rawSnapshot = stringifyJson(normalizedSnapshot); const rawConfig = options.rawConfig ?? null; - const accountDir = this.accountDirectory(name); - const authPath = this.accountAuthPath(name); - const metaPath = this.accountMetaPath(name); - const configPath = this.accountConfigPath(name); + const accountDir = this.repository.accountDirectory(name); + const authPath = this.repository.accountAuthPath(name); + const metaPath = this.repository.accountMetaPath(name); + const configPath = this.repository.accountConfigPath(name); const identity = getSnapshotIdentity(normalizedSnapshot); const accountExists = await pathExists(accountDir); const existingMeta = @@ -629,7 +261,7 @@ export class AccountStore { await rm(configPath, { force: true }); } - const meta = createSnapshotMeta( + const meta = this.repository.createSnapshotMeta( name, normalizedSnapshot, new Date(), @@ -639,11 +271,11 @@ export class AccountStore { meta.quota = existingMeta?.quota ?? meta.quota; await atomicWriteFile(metaPath, stringifyJson(meta)); - return this.readManagedAccount(name); + return await this.repository.readManagedAccount(name); } async updateCurrentManagedAccount(): Promise { - await this.ensureLayout(); + await this.repository.ensureLayout(); if (!(await pathExists(this.paths.currentAuthPath))) { throw new Error("Current ~/.codex/auth.json does not exist."); @@ -667,27 +299,27 @@ export class AccountStore { (await pathExists(this.paths.currentConfigPath)) ? await readJsonFile(this.paths.currentConfigPath) : null; - const metaPath = this.accountMetaPath(name); + const metaPath = this.repository.accountMetaPath(name); const existingMeta = parseSnapshotMeta(await readJsonFile(metaPath)); - this.validateConfigSnapshot(name, currentSnapshot, currentRawConfig); + validateConfigSnapshot(name, currentSnapshot, currentRawConfig); await atomicWriteFile( - this.accountAuthPath(name), + this.repository.accountAuthPath(name), `${currentRawSnapshot.trimEnd()}\n`, ); if (currentSnapshot.auth_mode === "apikey" && currentRawConfig) { await atomicWriteFile( - this.accountConfigPath(name), + this.repository.accountConfigPath(name), currentRawConfig.endsWith("\n") ? currentRawConfig : `${currentRawConfig}\n`, ); - } else if (await pathExists(this.accountConfigPath(name))) { - await rm(this.accountConfigPath(name), { force: true }); + } else if (await pathExists(this.repository.accountConfigPath(name))) { + await rm(this.repository.accountConfigPath(name), { force: true }); } await atomicWriteFile( metaPath, stringifyJson( { - ...createSnapshotMeta( + ...this.repository.createSnapshotMeta( name, currentSnapshot, new Date(), @@ -700,15 +332,15 @@ export class AccountStore { ); return { - account: await this.readManagedAccount(name), + account: await this.repository.readManagedAccount(name), }; } async switchAccount(name: string): Promise { ensureAccountName(name); - await this.ensureLayout(); + await this.repository.ensureLayout(); - const account = await this.readManagedAccount(name); + const account = await this.repository.readManagedAccount(name); const warnings: string[] = []; let backupPath: string | null = null; @@ -734,7 +366,7 @@ export class AccountStore { rawConfig.endsWith("\n") ? rawConfig : `${rawConfig}\n`, ); } else if (account.auth_mode === "apikey") { - await this.ensureEmptyAccountConfigSnapshot(name); + await this.repository.ensureEmptyAccountConfigSnapshot(name); warnings.push( `Saved apikey account "${name}" was missing config.toml snapshot. Created an empty snapshot; configure baseUrl manually if needed.`, ); @@ -742,7 +374,7 @@ export class AccountStore { const currentRawConfig = await readJsonFile(this.paths.currentConfigPath); await atomicWriteFile( this.paths.currentConfigPath, - this.sanitizeConfigForAccountAuth(currentRawConfig), + sanitizeConfigForAccountAuth(currentRawConfig), ); } const writtenSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); @@ -756,14 +388,14 @@ export class AccountStore { meta.updated_at = meta.last_switched_at; await atomicWriteFile(account.metaPath, stringifyJson(meta)); - await this.writeState({ + await this.repository.writeState({ schema_version: SCHEMA_VERSION, last_switched_account: name, last_backup_path: backupPath, }); return { - account: await this.readManagedAccount(name), + account: await this.repository.readManagedAccount(name), warnings, backup_path: backupPath, }; @@ -786,9 +418,9 @@ export class AccountStore { async refreshQuotaForAccount(name: string): Promise { ensureAccountName(name); - await this.ensureLayout(); + await this.repository.ensureLayout(); - const account = await this.readManagedAccount(name); + const account = await this.repository.readManagedAccount(name); const meta = parseSnapshotMeta(await readJsonFile(account.metaPath)); const snapshot = await readAuthSnapshotFile(account.authPath); const now = new Date(); @@ -801,8 +433,8 @@ export class AccountStore { }); if (JSON.stringify(result.authSnapshot) !== JSON.stringify(snapshot)) { - await this.writeAccountAuthSnapshot(name, result.authSnapshot); - await this.syncCurrentAuthIfMatching(result.authSnapshot); + await this.repository.writeAccountAuthSnapshot(name, result.authSnapshot); + await this.repository.syncCurrentAuthIfMatching(result.authSnapshot); } meta.auth_mode = result.authSnapshot.auth_mode; @@ -810,10 +442,10 @@ export class AccountStore { meta.user_id = getSnapshotUserId(result.authSnapshot); meta.updated_at = now.toISOString(); meta.quota = result.quota; - await this.writeAccountMeta(name, meta); + await this.repository.writeAccountMeta(name, meta); return { - account: await this.readManagedAccount(name), + account: await this.repository.readManagedAccount(name), quota: meta.quota, }; } catch (error) { @@ -833,7 +465,7 @@ export class AccountStore { fetched_at: now.toISOString(), error_message: (error as Error).message, }; - await this.writeAccountMeta(name, meta); + await this.repository.writeAccountMeta(name, meta); throw new Error(`Failed to refresh quota for "${name}": ${(error as Error).message}`); } } @@ -905,7 +537,7 @@ export class AccountStore { async removeAccount(name: string): Promise { ensureAccountName(name); - const accountDir = this.accountDirectory(name); + const accountDir = this.repository.accountDirectory(name); if (!(await pathExists(accountDir))) { throw new Error(`Account "${name}" does not exist.`); @@ -918,8 +550,8 @@ export class AccountStore { ensureAccountName(oldName); ensureAccountName(newName); - const oldDir = this.accountDirectory(oldName); - const newDir = this.accountDirectory(newName); + const oldDir = this.repository.accountDirectory(oldName); + const newDir = this.repository.accountDirectory(newName); if (!(await pathExists(oldDir))) { throw new Error(`Account "${oldName}" does not exist.`); @@ -930,17 +562,17 @@ export class AccountStore { } await rename(oldDir, newDir); - const metaPath = this.accountMetaPath(newName); + const metaPath = this.repository.accountMetaPath(newName); const meta = parseSnapshotMeta(await readJsonFile(metaPath)); meta.name = newName; meta.updated_at = new Date().toISOString(); await atomicWriteFile(metaPath, stringifyJson(meta)); - return this.readManagedAccount(newName); + return await this.repository.readManagedAccount(newName); } async doctor(): Promise { - await this.ensureLayout(); + await this.repository.ensureLayout(); const issues: string[] = []; const warnings: string[] = []; @@ -960,7 +592,7 @@ export class AccountStore { `State file permissions are ${(stateStat.mode & 0o777).toString(8)}, expected 600.`, ); } - await this.readState(); + await this.repository.readState(); } const { accounts, warnings: accountWarnings } = await this.listAccounts(); From 61b3bd53c2f139ff973c7691753cf7a0e7406d0f Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 20:20:05 +0800 Subject: [PATCH 5/6] refactor(layout): group account, desktop, and watch modules --- .../config.ts} | 2 +- src/account-store/index.ts | 2 ++ .../repository.ts} | 6 +++--- .../service.ts} | 14 ++++++------- .../storage.ts} | 2 +- .../types.ts} | 2 +- src/cli/help.ts | 2 +- src/cli/quota.ts | 6 +++--- src/codex-cli-runner.ts | 2 +- src/commands/account-management.ts | 2 +- src/commands/completion.ts | 2 +- src/commands/desktop.ts | 12 +++++------ src/commands/inspection.ts | 6 +++--- .../devtools.ts} | 4 ++-- src/desktop/index.ts | 8 ++++++++ .../launcher.ts} | 20 +++++++++---------- .../managed-state.ts} | 8 ++++---- .../process.ts} | 4 ++-- .../runtime.ts} | 4 ++-- .../shared.ts} | 0 .../state.ts} | 4 ++-- .../types.ts} | 0 src/main.ts | 8 ++++---- src/platform-desktop-adapter.ts | 4 ++-- src/switching.ts | 6 +++--- .../cli-watcher.ts} | 4 ++-- src/{watch-detached.ts => watch/detached.ts} | 2 +- src/{watch-history.ts => watch/history.ts} | 2 +- src/watch/index.ts | 6 ++++++ src/{watch-output.ts => watch/output.ts} | 6 +++--- src/{watch-process.ts => watch/process.ts} | 0 src/{watch-session.ts => watch/session.ts} | 16 +++++++-------- tests/account-store.test.ts | 2 +- tests/auto-switch-ranking.test.ts | 2 +- tests/cli-account-commands.test.ts | 2 +- tests/cli-fixtures.ts | 4 ++-- tests/cli-launch.test.ts | 2 +- tests/cli-read-commands.test.ts | 2 +- tests/cli.test.ts | 2 +- tests/codex-cli-watcher.test.ts | 2 +- tests/codex-desktop-launch.test.ts | 2 +- tests/codex-runtime-read-paths.test.ts | 2 +- tests/desktop-managed-state.test.ts | 2 +- tests/platform-desktop-adapter.test.ts | 2 +- tests/watch-detached.test.ts | 2 +- tests/watch-history.test.ts | 4 ++-- tests/watch-output.test.ts | 2 +- 47 files changed, 108 insertions(+), 92 deletions(-) rename src/{account-store-config.ts => account-store/config.ts} (96%) create mode 100644 src/account-store/index.ts rename src/{account-store-repository.ts => account-store/repository.ts} (97%) rename src/{account-store.ts => account-store/service.ts} (98%) rename src/{account-store-storage.ts => account-store/storage.ts} (97%) rename src/{account-store-types.ts => account-store/types.ts} (97%) rename src/{codex-desktop-devtools.ts => desktop/devtools.ts} (98%) create mode 100644 src/desktop/index.ts rename src/{codex-desktop-launch.ts => desktop/launcher.ts} (98%) rename src/{desktop-managed-state.ts => desktop/managed-state.ts} (95%) rename src/{codex-desktop-process.ts => desktop/process.ts} (95%) rename src/{codex-desktop-runtime.ts => desktop/runtime.ts} (99%) rename src/{codex-desktop-shared.ts => desktop/shared.ts} (100%) rename src/{codex-desktop-state.ts => desktop/state.ts} (89%) rename src/{codex-desktop-types.ts => desktop/types.ts} (100%) rename src/{codex-cli-watcher.ts => watch/cli-watcher.ts} (99%) rename src/{watch-detached.ts => watch/detached.ts} (91%) rename src/{watch-history.ts => watch/history.ts} (99%) create mode 100644 src/watch/index.ts rename src/{watch-output.ts => watch/output.ts} (94%) rename src/{watch-process.ts => watch/process.ts} (100%) rename src/{watch-session.ts => watch/session.ts} (97%) diff --git a/src/account-store-config.ts b/src/account-store/config.ts similarity index 96% rename from src/account-store-config.ts rename to src/account-store/config.ts index d0e5e8b..697fdeb 100644 --- a/src/account-store-config.ts +++ b/src/account-store/config.ts @@ -1,4 +1,4 @@ -import type { AuthSnapshot } from "./auth-snapshot.js"; +import type { AuthSnapshot } from "../auth-snapshot.js"; export function validateConfigSnapshot( name: string, diff --git a/src/account-store/index.ts b/src/account-store/index.ts new file mode 100644 index 0000000..e08791e --- /dev/null +++ b/src/account-store/index.ts @@ -0,0 +1,2 @@ +export * from "./service.js"; +export type * from "./types.js"; diff --git a/src/account-store-repository.ts b/src/account-store/repository.ts similarity index 97% rename from src/account-store-repository.ts rename to src/account-store/repository.ts index c4b2409..c535fe8 100644 --- a/src/account-store-repository.ts +++ b/src/account-store/repository.ts @@ -11,8 +11,8 @@ import { isSupportedChatGPTAuthMode, parseSnapshotMeta, readAuthSnapshotFile, -} from "./auth-snapshot.js"; -import type { ManagedAccount, StorePaths, StoreState } from "./account-store-types.js"; +} from "../auth-snapshot.js"; +import type { ManagedAccount, StorePaths, StoreState } from "./types.js"; import { DIRECTORY_MODE, FILE_MODE, @@ -23,7 +23,7 @@ import { pathExists, readJsonFile, stringifyJson, -} from "./account-store-storage.js"; +} from "./storage.js"; function canAutoMigrateLegacyChatGPTMeta( meta: SnapshotMeta, diff --git a/src/account-store.ts b/src/account-store/service.ts similarity index 98% rename from src/account-store.ts rename to src/account-store/service.ts index 9045a1e..2d33427 100644 --- a/src/account-store.ts +++ b/src/account-store/service.ts @@ -15,9 +15,9 @@ import { parseAuthSnapshot, parseSnapshotMeta, readAuthSnapshotFile, -} from "./auth-snapshot.js"; -import { AccountStoreRepository } from "./account-store-repository.js"; -import { sanitizeConfigForAccountAuth, validateConfigSnapshot } from "./account-store-config.js"; +} from "../auth-snapshot.js"; +import { AccountStoreRepository } from "./repository.js"; +import { sanitizeConfigForAccountAuth, validateConfigSnapshot } from "./config.js"; import { DIRECTORY_MODE, FILE_MODE, @@ -31,7 +31,7 @@ import { pathExists, readJsonFile, stringifyJson, -} from "./account-store-storage.js"; +} from "./storage.js"; import type { AccountQuotaSummary, CurrentAccountStatus, @@ -42,11 +42,11 @@ import type { StorePaths, UpdateAccountResult, SwitchAccountResult, -} from "./account-store-types.js"; +} from "./types.js"; import { extractChatGPTAuth, fetchQuotaSnapshot, -} from "./quota-client.js"; +} from "../quota-client.js"; export type { AccountQuotaSummary, CurrentAccountStatus, @@ -57,7 +57,7 @@ export type { StorePaths, SwitchAccountResult, UpdateAccountResult, -} from "./account-store-types.js"; +} from "./types.js"; export class AccountStore { readonly paths: StorePaths; diff --git a/src/account-store-storage.ts b/src/account-store/storage.ts similarity index 97% rename from src/account-store-storage.ts rename to src/account-store/storage.ts index bd3a79b..b92611d 100644 --- a/src/account-store-storage.ts +++ b/src/account-store/storage.ts @@ -2,7 +2,7 @@ import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; -import type { StorePaths } from "./account-store-types.js"; +import type { StorePaths } from "./types.js"; export const DIRECTORY_MODE = 0o700; export const FILE_MODE = 0o600; diff --git a/src/account-store-types.ts b/src/account-store/types.ts similarity index 97% rename from src/account-store-types.ts rename to src/account-store/types.ts index d750e58..f0b7eb4 100644 --- a/src/account-store-types.ts +++ b/src/account-store/types.ts @@ -1,4 +1,4 @@ -import type { QuotaSnapshot, QuotaStatus, QuotaWindowSnapshot, SnapshotMeta } from "./auth-snapshot.js"; +import type { QuotaSnapshot, QuotaStatus, QuotaWindowSnapshot, SnapshotMeta } from "../auth-snapshot.js"; export interface StorePaths { homeDir: string; diff --git a/src/cli/help.ts b/src/cli/help.ts index 82a1c2a..553d1ee 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,4 +1,4 @@ -import type { AccountStore } from "../account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import { COMMAND_FLAGS, COMMAND_NAMES, GLOBAL_FLAGS } from "./args.js"; const COMPLETION_ACCOUNT_COMMANDS = new Set(["launch", "list", "remove", "rename", "switch"] as const); diff --git a/src/cli/quota.ts b/src/cli/quota.ts index 19a59df..e9ebef6 100644 --- a/src/cli/quota.ts +++ b/src/cli/quota.ts @@ -3,15 +3,15 @@ import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; import { maskAccountId } from "../auth-snapshot.js"; -import type { AccountQuotaSummary } from "../account-store.js"; -import type { RuntimeQuotaSnapshot } from "../codex-desktop-launch.js"; +import type { AccountQuotaSummary } from "../account-store/index.js"; +import type { RuntimeQuotaSnapshot } from "../desktop/launcher.js"; import { convertFiveHourPercentToPlusWeeklyUnits, convertOneWeekPercentToPlusWeeklyUnits, normalizeDisplayedScore, resolveFiveHourWindowsPerWeek, } from "../plan-quota-profile.js"; -import type { WatchHistoryEtaContext } from "../watch-history.js"; +import type { WatchHistoryEtaContext } from "../watch/history.js"; dayjs.extend(utc); dayjs.extend(timezone); diff --git a/src/codex-cli-runner.ts b/src/codex-cli-runner.ts index ed2aae6..52ed36a 100644 --- a/src/codex-cli-runner.ts +++ b/src/codex-cli-runner.ts @@ -42,7 +42,7 @@ import { createHash } from "node:crypto"; import { createCliProcessManager, type CliProcessManager, -} from "./codex-cli-watcher.js"; +} from "./watch/cli-watcher.js"; // ── Types ── diff --git a/src/commands/account-management.ts b/src/commands/account-management.ts index 1482a77..90ec27a 100644 --- a/src/commands/account-management.ts +++ b/src/commands/account-management.ts @@ -1,4 +1,4 @@ -import type { AccountStore } from "../account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import type { CodexLoginProvider } from "../codex-login.js"; import { maskAccountId } from "../auth-snapshot.js"; import { toCliQuotaSummary } from "../cli/quota.js"; diff --git a/src/commands/completion.ts b/src/commands/completion.ts index edf4e74..a0fd289 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -1,4 +1,4 @@ -import type { AccountStore } from "../account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import { buildCompletionBashScript, buildCompletionZshScript, diff --git a/src/commands/desktop.ts b/src/commands/desktop.ts index 5c37569..3775973 100644 --- a/src/commands/desktop.ts +++ b/src/commands/desktop.ts @@ -1,17 +1,17 @@ -import type { AccountStore } from "../account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import { maskAccountId } from "../auth-snapshot.js"; import type { ParsedArgs } from "../cli/args.js"; -import type { CodexDesktopLauncher } from "../codex-desktop-launch.js"; +import type { CodexDesktopLauncher } from "../desktop/launcher.js"; import { writeJson } from "../cli/output.js"; import { confirmDesktopRelaunch, isOnlyManagedDesktopInstanceRunning, resolveManagedDesktopState, restoreLaunchBackup, -} from "../desktop-managed-state.js"; +} from "../desktop/managed-state.js"; import { getPlatform } from "../platform.js"; -import type { WatchProcessManager } from "../watch-process.js"; -import { ensureDetachedWatch } from "../watch-detached.js"; +import type { WatchProcessManager } from "../watch/process.js"; +import { ensureDetachedWatch } from "../watch/detached.js"; import { describeBusySwitchLock, resolveManagedAccountByName, @@ -19,7 +19,7 @@ import { stripManagedDesktopWarning, tryAcquireSwitchLock, } from "../switching.js"; -import { runCliWatchSession, runManagedDesktopWatchSession } from "../watch-session.js"; +import { runCliWatchSession, runManagedDesktopWatchSession } from "../watch/session.js"; const INTERNAL_LAUNCH_REFUSAL_MESSAGE = 'Refusing to run "codexm launch" from inside Codex Desktop because quitting the app would terminate this session. Run this command from an external terminal instead.'; diff --git a/src/commands/inspection.ts b/src/commands/inspection.ts index 9bdeb34..1711347 100644 --- a/src/commands/inspection.ts +++ b/src/commands/inspection.ts @@ -4,12 +4,12 @@ import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; import { getSnapshotIdentity, maskAccountId, parseAuthSnapshot } from "../auth-snapshot.js"; -import type { AccountStore } from "../account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import type { CodexDesktopLauncher, RuntimeAccountSnapshot, RuntimeReadSource, -} from "../codex-desktop-launch.js"; +} from "../desktop/launcher.js"; import { computeAvailability, describeCurrentUsageSummary, @@ -24,7 +24,7 @@ import { computeWatchHistoryEta, createWatchHistoryStore, type WatchHistoryEtaContext, -} from "../watch-history.js"; +} from "../watch/history.js"; dayjs.extend(utc); dayjs.extend(timezone); diff --git a/src/codex-desktop-devtools.ts b/src/desktop/devtools.ts similarity index 98% rename from src/codex-desktop-devtools.ts rename to src/desktop/devtools.ts index 9353487..cd87fb5 100644 --- a/src/codex-desktop-devtools.ts +++ b/src/desktop/devtools.ts @@ -1,9 +1,9 @@ -import type { ManagedCodexDesktopState } from "./codex-desktop-types.js"; +import type { ManagedCodexDesktopState } from "./types.js"; import { CODEX_LOCAL_HOST_ID, isNonEmptyString, isRecord, -} from "./codex-desktop-shared.js"; +} from "./shared.js"; export interface FetchLikeResponse { ok: boolean; diff --git a/src/desktop/index.ts b/src/desktop/index.ts new file mode 100644 index 0000000..6ec1a72 --- /dev/null +++ b/src/desktop/index.ts @@ -0,0 +1,8 @@ +export * from "./launcher.js"; +export * from "./managed-state.js"; +export * from "./devtools.js"; +export * from "./process.js"; +export * from "./runtime.js"; +export * from "./shared.js"; +export * from "./state.js"; +export type * from "./types.js"; diff --git a/src/codex-desktop-launch.ts b/src/desktop/launcher.ts similarity index 98% rename from src/codex-desktop-launch.ts rename to src/desktop/launcher.ts index 8fd72c8..0bea2ef 100644 --- a/src/codex-desktop-launch.ts +++ b/src/desktop/launcher.ts @@ -7,7 +7,7 @@ import { promisify } from "node:util"; import { createCodexDirectClient, type CodexDirectClient, -} from "./codex-direct-client.js"; +} from "../codex-direct-client.js"; import { createDefaultWebSocket, evaluateDevtoolsExpression, @@ -15,14 +15,14 @@ import { resolveLocalDevtoolsTarget, type CreateWebSocketLike, type FetchLike, -} from "./codex-desktop-devtools.js"; +} from "./devtools.js"; import { isManagedDesktopProcess, launchManagedDesktopProcess, pathExistsViaStat, readProcessParentAndCommand, type LaunchProcessLike, -} from "./codex-desktop-process.js"; +} from "./process.js"; import { CODEX_APP_NAME, CODEX_APP_SERVER_RESTART_EXPRESSION, @@ -39,8 +39,8 @@ import { isRecord, toErrorMessage, waitForPromiseOrAbort, -} from "./codex-desktop-shared.js"; -import { ensureStateDirectory, parseManagedState } from "./codex-desktop-state.js"; +} from "./shared.js"; +import { ensureStateDirectory, parseManagedState } from "./state.js"; import { buildManagedCurrentAccountExpression, buildManagedCurrentQuotaExpression, @@ -54,7 +54,7 @@ import { normalizeBridgeProbePayload, normalizeRuntimeAccountSnapshot, normalizeRuntimeQuotaSnapshot, -} from "./codex-desktop-runtime.js"; +} from "./runtime.js"; import type { CodexDesktopLauncher, ExecFileLike, @@ -66,8 +66,8 @@ import type { RuntimeAccountSnapshot, RuntimeQuotaSnapshot, RuntimeReadResult, -} from "./codex-desktop-types.js"; -export type { CodexDirectClient } from "./codex-direct-client.js"; +} from "./types.js"; +export type { CodexDirectClient } from "../codex-direct-client.js"; export type { CodexDesktopLauncher, ExecFileLike, @@ -82,11 +82,11 @@ export type { RuntimeQuotaSnapshot, RuntimeReadResult, RuntimeReadSource, -} from "./codex-desktop-types.js"; +} from "./types.js"; export { DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, -} from "./codex-desktop-shared.js"; +} from "./shared.js"; const execFile = promisify(execFileCallback); diff --git a/src/desktop-managed-state.ts b/src/desktop/managed-state.ts similarity index 95% rename from src/desktop-managed-state.ts rename to src/desktop/managed-state.ts index b73ccb3..459c44e 100644 --- a/src/desktop-managed-state.ts +++ b/src/desktop/managed-state.ts @@ -1,15 +1,15 @@ import { copyFile, readFile, rm, stat } from "node:fs/promises"; import { join } from "node:path"; -import { getSnapshotEmail, parseAuthSnapshot } from "./auth-snapshot.js"; -import type { AccountStore } from "./account-store.js"; +import { getSnapshotEmail, parseAuthSnapshot } from "../auth-snapshot.js"; +import type { AccountStore } from "../account-store/index.js"; import { DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, type CodexDesktopLauncher, type ManagedCodexDesktopState, type RunningCodexDesktop, -} from "./codex-desktop-launch.js"; -import { isCodexDesktopCommand, type CodexmPlatform } from "./platform.js"; +} from "../desktop/launcher.js"; +import { isCodexDesktopCommand, type CodexmPlatform } from "../platform.js"; interface CliStreams { stdin: NodeJS.ReadStream; diff --git a/src/codex-desktop-process.ts b/src/desktop/process.ts similarity index 95% rename from src/codex-desktop-process.ts rename to src/desktop/process.ts index 6d79b06..163fdc0 100644 --- a/src/codex-desktop-process.ts +++ b/src/desktop/process.ts @@ -4,8 +4,8 @@ import type { ExecFileLike, ManagedCodexDesktopState, RunningCodexDesktop, -} from "./codex-desktop-types.js"; -import { CODEX_BINARY_SUFFIX } from "./codex-desktop-shared.js"; +} from "./types.js"; +import { CODEX_BINARY_SUFFIX } from "./shared.js"; export type LaunchProcessLike = (options: { appPath: string; diff --git a/src/codex-desktop-runtime.ts b/src/desktop/runtime.ts similarity index 99% rename from src/codex-desktop-runtime.ts rename to src/desktop/runtime.ts index 037b6c5..b5499b5 100644 --- a/src/codex-desktop-runtime.ts +++ b/src/desktop/runtime.ts @@ -3,7 +3,7 @@ import type { ManagedWatchActivitySignal, RuntimeAccountSnapshot, RuntimeQuotaSnapshot, -} from "./codex-desktop-types.js"; +} from "./types.js"; import { buildCodexDesktopGuardExpression, CODEXM_WATCH_CONSOLE_PREFIX, @@ -12,7 +12,7 @@ import { DEVTOOLS_REQUEST_TIMEOUT_MS, normalizeBodySnippet, isRecord, -} from "./codex-desktop-shared.js"; +} from "./shared.js"; interface ProbeConsolePayload { kind?: unknown; diff --git a/src/codex-desktop-shared.ts b/src/desktop/shared.ts similarity index 100% rename from src/codex-desktop-shared.ts rename to src/desktop/shared.ts diff --git a/src/codex-desktop-state.ts b/src/desktop/state.ts similarity index 89% rename from src/codex-desktop-state.ts rename to src/desktop/state.ts index 7d80bc1..1eb8e57 100644 --- a/src/codex-desktop-state.ts +++ b/src/desktop/state.ts @@ -1,8 +1,8 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import type { ManagedCodexDesktopState } from "./codex-desktop-types.js"; -import { isNonEmptyString, isRecord } from "./codex-desktop-shared.js"; +import type { ManagedCodexDesktopState } from "./types.js"; +import { isNonEmptyString, isRecord } from "./shared.js"; export function parseManagedState(raw: string): ManagedCodexDesktopState | null { if (raw.trim() === "") { diff --git a/src/codex-desktop-types.ts b/src/desktop/types.ts similarity index 100% rename from src/codex-desktop-types.ts rename to src/desktop/types.ts diff --git a/src/main.ts b/src/main.ts index ee486ac..53de31c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,12 +6,12 @@ import { getSnapshotAccountId, getSnapshotEmail, maskAccountId, parseAuthSnapsho import { AccountStore, createAccountStore, -} from "./account-store.js"; -import { type CodexDesktopLauncher } from "./codex-desktop-launch.js"; +} from "./account-store/index.js"; +import { type CodexDesktopLauncher } from "./desktop/launcher.js"; import { createWatchProcessManager, type WatchProcessManager, -} from "./watch-process.js"; +} from "./watch/process.js"; import { createCodexLoginProvider, type CodexLoginProvider, @@ -64,7 +64,7 @@ import { } from "./platform-desktop-adapter.js"; import { shouldSkipManagedDesktopRefresh, -} from "./desktop-managed-state.js"; +} from "./desktop/managed-state.js"; export { rankAutoSwitchCandidates } from "./cli/quota.js"; diff --git a/src/platform-desktop-adapter.ts b/src/platform-desktop-adapter.ts index c3983ae..95f1295 100644 --- a/src/platform-desktop-adapter.ts +++ b/src/platform-desktop-adapter.ts @@ -22,8 +22,8 @@ import type { ExecFileLike, RunningCodexDesktop, CodexDesktopLauncher, -} from "./codex-desktop-launch.js"; -import { createCodexDesktopLauncher } from "./codex-desktop-launch.js"; +} from "./desktop/launcher.js"; +import { createCodexDesktopLauncher } from "./desktop/launcher.js"; const execFile = promisify(execFileCallback); diff --git a/src/switching.ts b/src/switching.ts index f345669..5038c61 100644 --- a/src/switching.ts +++ b/src/switching.ts @@ -4,14 +4,14 @@ import { join } from "node:path"; import type { AccountQuotaSummary, AccountStore, -} from "./account-store.js"; +} from "./account-store/index.js"; import type { CodexDesktopLauncher, RuntimeQuotaSnapshot, -} from "./codex-desktop-launch.js"; +} from "./desktop/launcher.js"; import { DEFAULT_MANAGED_DESKTOP_SWITCH_TIMEOUT_MS, -} from "./codex-desktop-launch.js"; +} from "./desktop/launcher.js"; import { rankAutoSwitchCandidates, toCliQuotaSummary, diff --git a/src/codex-cli-watcher.ts b/src/watch/cli-watcher.ts similarity index 99% rename from src/codex-cli-watcher.ts rename to src/watch/cli-watcher.ts index 80482f6..0c8c544 100644 --- a/src/codex-cli-watcher.ts +++ b/src/watch/cli-watcher.ts @@ -32,7 +32,7 @@ import { dirname, join } from "node:path"; import { createCodexDirectClient, type CodexDirectClient, -} from "./codex-direct-client.js"; +} from "../codex-direct-client.js"; import type { RuntimeQuotaSnapshot, @@ -40,7 +40,7 @@ import type { ManagedQuotaSignal, ManagedWatchActivitySignal, ManagedWatchStatusEvent, -} from "./codex-desktop-launch.js"; +} from "../desktop/launcher.js"; // ── Constants ── diff --git a/src/watch-detached.ts b/src/watch/detached.ts similarity index 91% rename from src/watch-detached.ts rename to src/watch/detached.ts index d233e74..8c7140b 100644 --- a/src/watch-detached.ts +++ b/src/watch/detached.ts @@ -1,4 +1,4 @@ -import type { WatchProcessManager, WatchProcessState } from "./watch-process.js"; +import type { WatchProcessManager, WatchProcessState } from "./process.js"; export async function ensureDetachedWatch( watchProcessManager: Pick, diff --git a/src/watch-history.ts b/src/watch/history.ts similarity index 99% rename from src/watch-history.ts rename to src/watch/history.ts index 932c9c6..13f7bce 100644 --- a/src/watch-history.ts +++ b/src/watch/history.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { convertFiveHourPercentToPlusWeeklyUnits, convertOneWeekPercentToPlusWeeklyUnits, -} from "./plan-quota-profile.js"; +} from "../plan-quota-profile.js"; const WATCH_HISTORY_FILE_NAME = "watch-quota-history.jsonl"; const WATCH_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; diff --git a/src/watch/index.ts b/src/watch/index.ts new file mode 100644 index 0000000..b244867 --- /dev/null +++ b/src/watch/index.ts @@ -0,0 +1,6 @@ +export * from "./session.js"; +export * from "./output.js"; +export * from "./history.js"; +export * from "./detached.js"; +export * from "./process.js"; +export * from "./cli-watcher.js"; diff --git a/src/watch-output.ts b/src/watch/output.ts similarity index 94% rename from src/watch-output.ts rename to src/watch/output.ts index 59088b9..171b91b 100644 --- a/src/watch-output.ts +++ b/src/watch/output.ts @@ -1,8 +1,8 @@ import dayjs from "dayjs"; -import type { AccountStore } from "./account-store.js"; -import type { CliQuotaSummary } from "./cli/quota.js"; -import type { ManagedWatchStatusEvent } from "./codex-desktop-launch.js"; +import type { AccountStore } from "../account-store/index.js"; +import type { CliQuotaSummary } from "../cli/quota.js"; +import type { ManagedWatchStatusEvent } from "../desktop/launcher.js"; function formatWatchField(key: string, value: string | number): string { if (typeof value === "number") { diff --git a/src/watch-process.ts b/src/watch/process.ts similarity index 100% rename from src/watch-process.ts rename to src/watch/process.ts diff --git a/src/watch-session.ts b/src/watch/session.ts similarity index 97% rename from src/watch-session.ts rename to src/watch/session.ts index b1a45d4..b302a81 100644 --- a/src/watch-session.ts +++ b/src/watch/session.ts @@ -1,24 +1,24 @@ -import type { AccountStore } from "./account-store.js"; +import type { AccountStore } from "../account-store/index.js"; import type { CodexDesktopLauncher, ManagedQuotaSignal, ManagedWatchActivitySignal, -} from "./codex-desktop-launch.js"; -import { createCliProcessManager } from "./codex-cli-watcher.js"; +} from "../desktop/launcher.js"; +import { createCliProcessManager } from "./cli-watcher.js"; import { isTerminalWatchQuota, toCliQuotaSummaryFromRuntimeQuota, -} from "./cli/quota.js"; +} from "../cli/quota.js"; import { appendWatchQuotaHistory, createWatchHistoryStore, -} from "./watch-history.js"; +} from "./history.js"; import { describeBusySwitchLock, performAutoSwitch, tryAcquireSwitchLock, tryReadManagedDesktopQuota, -} from "./switching.js"; +} from "../switching.js"; import { describeWatchAutoSwitchEvent, describeWatchAutoSwitchSkippedEvent, @@ -26,7 +26,7 @@ import { describeWatchStatusEvent, formatWatchLogLine, resolveWatchAccountLabel, -} from "./watch-output.js"; +} from "./output.js"; const WATCH_AUTO_SWITCH_TIMEOUT_MS = 600_000; @@ -60,7 +60,7 @@ export async function runCliWatchSession(options: { managedDesktopWaitStatusIntervalMs, } = options; - const platformModule = await import("./platform.js"); + const platformModule = await import("../platform.js"); const platform = await platformModule.getPlatform(); debugLog(`watch: no managed Desktop detected, entering CLI watch mode (platform=${platform})`); streams.stderr.write( diff --git a/tests/account-store.test.ts b/tests/account-store.test.ts index 95e2543..375516f 100644 --- a/tests/account-store.test.ts +++ b/tests/account-store.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, test } from "@rstest/core"; -import { createAccountStore } from "../src/account-store.js"; +import { createAccountStore } from "../src/account-store/index.js"; import { parseAuthSnapshot } from "../src/auth-snapshot.js"; import { extractChatGPTAuth } from "../src/quota-client.js"; import { diff --git a/tests/auto-switch-ranking.test.ts b/tests/auto-switch-ranking.test.ts index 40f0565..2eb887c 100644 --- a/tests/auto-switch-ranking.test.ts +++ b/tests/auto-switch-ranking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "@rstest/core"; -import type { AccountQuotaSummary } from "../src/account-store.js"; +import type { AccountQuotaSummary } from "../src/account-store/index.js"; import { rankAutoSwitchCandidates } from "../src/cli/quota.js"; describe("auto switch ranking", () => { diff --git a/tests/cli-account-commands.test.ts b/tests/cli-account-commands.test.ts index 203dd22..30e4172 100644 --- a/tests/cli-account-commands.test.ts +++ b/tests/cli-account-commands.test.ts @@ -5,7 +5,7 @@ import { Readable } from "node:stream"; import { describe, expect, test } from "@rstest/core"; import { runCli } from "../src/main.js"; -import { createAccountStore } from "../src/account-store.js"; +import { createAccountStore } from "../src/account-store/index.js"; import { cleanupTempHome, createApiKeyPayload, diff --git a/tests/cli-fixtures.ts b/tests/cli-fixtures.ts index 374aeb6..e6c7287 100644 --- a/tests/cli-fixtures.ts +++ b/tests/cli-fixtures.ts @@ -9,8 +9,8 @@ import type { RuntimeQuotaSnapshot, RuntimeReadResult, RunningCodexDesktop, -} from "../src/codex-desktop-launch.js"; -import type { WatchProcessManager, WatchProcessState } from "../src/watch-process.js"; +} from "../src/desktop/launcher.js"; +import type { WatchProcessManager, WatchProcessState } from "../src/watch/process.js"; export function captureWritable(): { stream: NodeJS.WriteStream; diff --git a/tests/cli-launch.test.ts b/tests/cli-launch.test.ts index d61cfc2..8e767a9 100644 --- a/tests/cli-launch.test.ts +++ b/tests/cli-launch.test.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { describe, expect, test } from "@rstest/core"; import { runCli } from "../src/main.js"; -import { createAccountStore } from "../src/account-store.js"; +import { createAccountStore } from "../src/account-store/index.js"; import { cleanupTempHome, createTempHome, diff --git a/tests/cli-read-commands.test.ts b/tests/cli-read-commands.test.ts index 32db737..0c82dac 100644 --- a/tests/cli-read-commands.test.ts +++ b/tests/cli-read-commands.test.ts @@ -8,7 +8,7 @@ import utc from "dayjs/plugin/utc.js"; import packageJson from "../package.json"; import { runCli } from "../src/main.js"; -import { createAccountStore } from "../src/account-store.js"; +import { createAccountStore } from "../src/account-store/index.js"; import { cleanupTempHome, createTempHome, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d1699d9..aadee3b 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "@rstest/core"; import { runCli } from "../src/main.js"; -import { createAccountStore } from "../src/account-store.js"; +import { createAccountStore } from "../src/account-store/index.js"; import { cleanupTempHome, createTempHome, diff --git a/tests/codex-cli-watcher.test.ts b/tests/codex-cli-watcher.test.ts index 091fe55..ed99fbd 100644 --- a/tests/codex-cli-watcher.test.ts +++ b/tests/codex-cli-watcher.test.ts @@ -6,7 +6,7 @@ import { createCliProcessManager, type ExecFileLike, type TrackedCliProcess, -} from "../src/codex-cli-watcher.js"; +} from "../src/watch/cli-watcher.js"; import type { CodexDirectClient } from "../src/codex-direct-client.js"; // ── Test helpers ── diff --git a/tests/codex-desktop-launch.test.ts b/tests/codex-desktop-launch.test.ts index fa5006a..3721451 100644 --- a/tests/codex-desktop-launch.test.ts +++ b/tests/codex-desktop-launch.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "@rstest/core"; import { DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, createCodexDesktopLauncher, -} from "../src/codex-desktop-launch.js"; +} from "../src/desktop/launcher.js"; import { cleanupTempHome, createTempHome } from "./test-helpers.js"; describe("codex-desktop-launch", () => { diff --git a/tests/codex-runtime-read-paths.test.ts b/tests/codex-runtime-read-paths.test.ts index 8755fa5..083ef97 100644 --- a/tests/codex-runtime-read-paths.test.ts +++ b/tests/codex-runtime-read-paths.test.ts @@ -4,7 +4,7 @@ import { DEFAULT_CODEX_REMOTE_DEBUGGING_PORT, createCodexDesktopLauncher, type CodexDirectClient, -} from "../src/codex-desktop-launch.js"; +} from "../src/desktop/launcher.js"; import { cleanupTempHome, createTempHome } from "./test-helpers.js"; function createFakeDirectClient(responseByMethod: Record) { diff --git a/tests/desktop-managed-state.test.ts b/tests/desktop-managed-state.test.ts index 236f29e..4c43084 100644 --- a/tests/desktop-managed-state.test.ts +++ b/tests/desktop-managed-state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "@rstest/core"; import { isOnlyManagedDesktopInstanceRunning, isRunningDesktopFromApp, -} from "../src/desktop-managed-state.js"; +} from "../src/desktop/managed-state.js"; describe("desktop-managed-state", () => { test("matches the managed macOS Desktop binary path", () => { diff --git a/tests/platform-desktop-adapter.test.ts b/tests/platform-desktop-adapter.test.ts index b3dd775..c3d1106 100644 --- a/tests/platform-desktop-adapter.test.ts +++ b/tests/platform-desktop-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "@rstest/core"; -import type { ExecFileLike } from "../src/codex-desktop-launch.js"; +import type { ExecFileLike } from "../src/desktop/launcher.js"; // ── Helpers ── diff --git a/tests/watch-detached.test.ts b/tests/watch-detached.test.ts index 4064785..044fbb2 100644 --- a/tests/watch-detached.test.ts +++ b/tests/watch-detached.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "@rstest/core"; -import { ensureDetachedWatch } from "../src/watch-detached.js"; +import { ensureDetachedWatch } from "../src/watch/detached.js"; describe("watch-detached", () => { test("reuses an existing detached watch when options match", async () => { diff --git a/tests/watch-history.test.ts b/tests/watch-history.test.ts index 0e6f884..77449a9 100644 --- a/tests/watch-history.test.ts +++ b/tests/watch-history.test.ts @@ -5,11 +5,11 @@ import { computeWatchEtaContext, computeWatchHistoryEta, createWatchHistoryStore, -} from "../src/watch-history.js"; +} from "../src/watch/history.js"; import type { WatchHistoryRecord, WatchHistoryTargetSnapshot, -} from "../src/watch-history.js"; +} from "../src/watch/history.js"; import { cleanupTempHome, createTempHome } from "./test-helpers.js"; function makeWindow(used_percent: number, reset_at: string) { diff --git a/tests/watch-output.test.ts b/tests/watch-output.test.ts index 3e7921b..bc6353f 100644 --- a/tests/watch-output.test.ts +++ b/tests/watch-output.test.ts @@ -5,7 +5,7 @@ import { describeWatchAutoSwitchSkippedEvent, describeWatchQuotaEvent, describeWatchStatusEvent, -} from "../src/watch-output.js"; +} from "../src/watch/output.js"; describe("watch-output", () => { test("renders structured quota lines for usable quota", () => { From 1d110eae773484c37cd033e9589d22d6b9d3fa1f Mon Sep 17 00:00:00 2001 From: liyanbowne Date: Mon, 13 Apr 2026 20:37:23 +0800 Subject: [PATCH 6/6] test(cli): set launch tests to darwin platform --- AGENTS.md | 6 ++++-- tests/cli-launch.test.ts | 14 +++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73a66b0..866561d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,16 @@ Detailed design notes live in `docs/internal/`. - Do not add a command by querying both Desktop and direct runtime paths unless the command semantics explicitly require it. - Do not spread new platform-specific Desktop process logic outside the Desktop launcher boundary. - Do not duplicate plan or quota normalization rules outside `src/plan-quota-profile.ts`. +- Before adding legacy interface, data, or code compatibility paths, confirm with the user that backward compatibility is necessary. ## Module Boundaries - `src/main.ts`: CLI orchestration only. - `src/commands/*`: command handlers. -- `src/codex-desktop-launch.ts`: managed Desktop lifecycle, DevTools bridge, Desktop runtime reads, and watch stream handling. +- `src/desktop/launcher.ts`: managed Desktop lifecycle, DevTools bridge, Desktop runtime reads, and watch stream handling. - `src/codex-direct-client.ts`: direct `codex app-server` client for one-shot runtime reads. -- `src/watch-history.ts`: watch history persistence and ETA calculation. +- `src/watch/history.ts`: watch history persistence and ETA calculation. +- `src/account-store/service.ts`: account store orchestration and mutation flows. - `src/plan-quota-profile.ts`: centralized plan normalization and quota ratio rules. - `src/cli/quota.ts`: quota presentation, list ordering, and auto-switch candidate formatting. diff --git a/tests/cli-launch.test.ts b/tests/cli-launch.test.ts index 8e767a9..720297f 100644 --- a/tests/cli-launch.test.ts +++ b/tests/cli-launch.test.ts @@ -1,9 +1,10 @@ import { writeFile } from "node:fs/promises"; -import { describe, expect, test } from "@rstest/core"; +import { afterEach, beforeEach, describe, expect, test } from "@rstest/core"; import { runCli } from "../src/main.js"; import { createAccountStore } from "../src/account-store/index.js"; +import { setPlatformForTesting } from "../src/platform.js"; import { cleanupTempHome, createTempHome, @@ -20,6 +21,17 @@ import { } from "./cli-fixtures.js"; describe("CLI Launch", () => { + let restorePlatform: (() => void) | undefined; + + beforeEach(() => { + restorePlatform = setPlatformForTesting("darwin"); + }); + + afterEach(() => { + restorePlatform?.(); + restorePlatform = undefined; + }); + test("launches desktop with current auth when no account is provided", async () => { const homeDir = await createTempHome();