Improve Codex CLI fallback in Live

Detect a missing CLI before worker startup, keep Live usable through the foreground poller, and surface actionable status in Live and Live Lab.\n\nAI-assisted implementation.
This commit is contained in:
Paul Bakaus
2026-07-14 17:40:16 -07:00
parent c98f5d42ed
commit f46830fe42
13 changed files with 351 additions and 18 deletions
+33 -9
View File
@@ -6211,7 +6211,7 @@
hasProjectContext = !!msg.hasProjectContext;
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
console.log('[impeccable] Live mode connected.');
syncAgentPollingUi(!!msg.agentPolling);
syncAgentPollingUi(!!msg.agentPolling, msg.codexWorker);
startAgentStatusPoll();
restoreFromActiveSessions(msg.activeSessions, 'sse_connected');
if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING');
@@ -8312,6 +8312,9 @@ void main() {
let globalBarBrandEl = null;
let agentPollTooltipEl = null;
let agentPollingConnected = false;
let codexWorkerStatus = null;
let agentStatusMessage = null;
let codexWorkerFallbackToastShown = false;
let agentStatusPollTimer = null;
let steerFocusSuspended = false;
let steerFocusPauseUntil = 0;
@@ -8416,6 +8419,7 @@ void main() {
const AGENT_STATUS_POLL_MS = 5000;
const AGENT_DISCONNECTED_MARK = 'oklch(62% 0 0 / 0.78)';
const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect';
const CODEX_CLI_FALLBACK_TIP = 'Foreground mode: Codex CLI not found. Install it and run codex login for background variants.';
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
@@ -9436,24 +9440,41 @@ void main() {
</svg>`;
}
function syncAgentPollingUi(connected) {
function syncAgentPollingUi(connected, workerStatus) {
if (workerStatus !== undefined) codexWorkerStatus = workerStatus;
agentPollingConnected = !!connected;
if (!globalBarBrandEl) return;
const P = barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
const cliUnavailable = codexWorkerStatus?.error === 'codex_cli_unavailable';
const workerUnavailable = cliUnavailable
|| ['error', 'failed', 'unavailable'].includes(codexWorkerStatus?.status);
agentStatusMessage = !connected
? AGENT_DISCONNECTED_TIP
: cliUnavailable
? CODEX_CLI_FALLBACK_TIP
: workerUnavailable
? 'Foreground mode: background generation is unavailable. Check .impeccable/live/codex-worker.log.'
: null;
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
globalBarBrandEl.setAttribute('aria-label', connected
? 'Impeccable live mode'
? workerUnavailable
? 'Impeccable live mode - using foreground generation'
: 'Impeccable live mode'
: 'Impeccable live mode - agent not polling');
globalBarBrandEl.removeAttribute('title');
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
globalBarBrandEl.style.cursor = agentStatusMessage ? 'help' : 'default';
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
if (mark) {
mark.innerHTML = brandMarkSvg(connected ? P.accent : AGENT_DISCONNECTED_MARK, 18);
mark.style.opacity = '1';
}
const dot = globalBarBrandEl.querySelector('[data-agent-dot]');
if (dot) dot.style.display = connected ? 'none' : 'block';
if (connected) hideAgentPollTooltip();
if (dot) dot.style.display = agentStatusMessage ? 'block' : 'none';
if (!agentStatusMessage) hideAgentPollTooltip();
if (cliUnavailable && !codexWorkerFallbackToastShown) {
codexWorkerFallbackToastShown = true;
showToast('Codex CLI is unavailable. Live is using the main agent, so generation may take longer. Install the CLI, run codex login, then restart Live.', 9000);
}
}
function ensureAgentPollTooltip() {
@@ -9480,14 +9501,15 @@ void main() {
whiteSpace: 'normal',
});
agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip';
agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP;
agentPollTooltipEl.textContent = agentStatusMessage || AGENT_DISCONNECTED_TIP;
uiAppend(agentPollTooltipEl);
return agentPollTooltipEl;
}
function showAgentPollTooltip(anchor) {
if (agentPollingConnected || !anchor) return;
if (!agentStatusMessage || !anchor) return;
const tip = ensureAgentPollTooltip();
tip.textContent = agentStatusMessage;
tip.style.transition = 'none';
tip.style.display = 'block';
tip.style.opacity = '1';
@@ -9517,7 +9539,9 @@ void main() {
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data && typeof data.agentPolling === 'boolean') syncAgentPollingUi(data.agentPolling);
if (data && typeof data.agentPolling === 'boolean') {
syncAgentPollingUi(data.agentPolling, data.codexWorker);
}
})
.catch(() => { /* server loss handled elsewhere */ });
}
+26
View File
@@ -7,9 +7,11 @@ import { fileURLToPath } from 'node:url';
import { createCodexAppServerClient } from './live/codex-app-server-client.mjs';
import {
CODEX_CLI_SETUP_URL,
CODEX_WORKER_OWNER,
codexWorkerProcessStateIsOwned,
codexWorkerStateIsOwned,
resolveCodexExecutable,
resolveCodexWorkerConfig,
} from './live/codex-worker.mjs';
import { CodexLiveWorkerSupervisor } from './live/codex-worker-supervisor.mjs';
@@ -104,6 +106,30 @@ if (args.includes('--background')) {
console.log(JSON.stringify({ ...existing, ok: true, reused: true }));
process.exit(0);
}
}
const executable = resolveCodexExecutable(config.codexPath, { cwd, env: process.env });
if (!executable.available) {
const unavailable = writeState({
ok: false,
owner: CODEX_WORKER_OWNER,
pid: null,
status: 'unavailable',
mode: 'foreground',
error: executable.error,
command: executable.command,
message: 'Codex CLI not found. Live is using the main agent for generation.',
setup: {
docsUrl: CODEX_CLI_SETUP_URL,
afterInstall: 'codex login',
},
});
console.log(JSON.stringify({ ...unavailable, fallback: 'foreground' }));
process.exit(0);
}
config.codexPath = executable.resolvedPath;
if (args.includes('--background')) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const logPath = path.join(path.dirname(statePath), 'codex-worker.log');
const logFd = fs.openSync(logPath, 'a');
+55
View File
@@ -36,6 +36,7 @@ import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveCodexWorkerStatePath,
getLiveDir,
getLiveAnnotationsDir,
IMPECCABLE_COMMAND_PREFIX,
@@ -485,6 +486,58 @@ function getManualEditStatus() {
}
}
function getCodexWorkerStatus() {
let worker;
try {
worker = JSON.parse(fs.readFileSync(getLiveCodexWorkerStatePath(process.cwd()), 'utf-8'));
} catch {
return null;
}
if (!worker || typeof worker !== 'object') return null;
const processActive = Number.isInteger(worker.pid) && worker.pid > 0 && pidReachable(worker.pid);
const activeStatus = ['starting', 'ready', 'working'].includes(worker.status);
const unavailable = activeStatus && !processActive;
const error = unavailable ? 'codex_worker_unavailable' : stringOrNull(worker.error);
const status = unavailable ? 'unavailable' : stringOrNull(worker.status) || 'unknown';
return {
status,
mode: stringOrNull(worker.mode)
|| (activeStatus && processActive ? 'dedicated-app-server' : 'foreground'),
reachable: processActive,
error,
message: worker.error === 'codex_cli_unavailable'
? 'Codex CLI not found. Live is using the main agent for generation.'
: error
? 'Background generation is unavailable. Live is using the main agent.'
: null,
command: stringOrNull(worker.command),
setup: worker.error === 'codex_cli_unavailable' && worker.setup
? {
docsUrl: stringOrNull(worker.setup.docsUrl),
afterInstall: stringOrNull(worker.setup.afterInstall),
}
: null,
model: stringOrNull(worker.model),
profile: stringOrNull(worker.profile),
delivery: stringOrNull(worker.delivery),
updatedAt: stringOrNull(worker.updatedAt),
};
}
function stringOrNull(value) {
return typeof value === 'string' && value.trim() ? value : null;
}
function pidReachable(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === 'EPERM';
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
@@ -667,6 +720,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
connectedClients: state.sseClients.size,
pendingEvents: state.pendingEvents.map((entry) => summarizePendingEventForStatus(entry)),
agentPolling: agentPollingConnected(),
codexWorker: getCodexWorkerStatus(),
activeSessions: sessions,
manualEdits: getManualEditStatus(),
}));
@@ -776,6 +830,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
type: 'connected',
hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(),
codexWorker: getCodexWorkerStatus(),
activeSessions: activeSessionSummaries(),
}) + '\n\n');
+13 -5
View File
@@ -36,16 +36,24 @@ export async function statusCli() {
agentPolling: server.agentPolling,
pendingEvents: server.pendingEvents,
} : null,
codexWorker: server?.codexWorker || null,
activeSessions: server?.activeSessions || activeSessions,
recoveryHint: manualApply
? manualApplyResumeHint(manualApply)
: server
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
recoveryHint: recoveryHint({ server, manualApply }),
};
console.log(JSON.stringify(payload, null, 2));
}
function recoveryHint({ server, manualApply }) {
if (manualApply) return manualApplyResumeHint(manualApply);
if (server?.codexWorker?.error === 'codex_cli_unavailable') {
return `Install Codex CLI (${server.codexWorker.setup?.docsUrl}), run ${server.codexWorker.setup?.afterInstall || 'codex login'}, then restart Live. The current session can continue through live-poll.mjs.`;
}
if (server) {
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
}
return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
}
function findPendingManualApply(server, activeSessions) {
const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
if (fromServer) return fromServer;
+9
View File
@@ -309,8 +309,17 @@ function ensureCodexWorker(cwd, liveConfig) {
codexOnly: true,
fallback: safeFallback,
error: result?.error || 'codex_worker_start_failed',
message: result?.message || (safeFallback
? 'Dedicated Codex generation is unavailable. Live is using the main agent.'
: 'The dedicated Codex worker did not stop cleanly.'),
command: result?.command || config.codexPath,
setup: result?.setup || null,
childPid: result?.childPid || null,
logPath: result?.logPath || null,
foregroundTypes: safeFallback
? ['generate', 'accept', 'discard', 'prefetch', 'steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit']
: [],
foregroundPoll: safeFallback ? 'live-poll.mjs' : null,
};
}
return {
+61
View File
@@ -8,6 +8,7 @@ import {
import { createLiveSessionStore } from './session-store.mjs';
export const CODEX_WORKER_OWNER = 'impeccable-live-codex-worker-v1';
export const CODEX_CLI_SETUP_URL = 'https://learn.chatgpt.com/docs/codex/cli';
const VARIANT_PLAN_SCHEMA = Object.freeze({
type: 'object',
properties: {
@@ -131,6 +132,66 @@ export function resolveCodexWorkerConfig({ env = process.env, liveConfig = {} }
};
}
/**
* Resolve the executable exactly as Node's spawn path would: explicit paths
* stay project-relative, while bare commands are searched on PATH. This is a
* filesystem-only preflight so Live can fall back synchronously without
* adding another Codex process to the initialization critical path.
*/
export function resolveCodexExecutable(command = 'codex', {
cwd = process.cwd(),
env = process.env,
platform = process.platform,
} = {}) {
const requested = String(command || '').trim();
if (!requested) {
return { available: false, error: 'codex_cli_unavailable', command: 'codex' };
}
const pathApi = platform === 'win32' ? path.win32 : path;
const pathLike = pathApi.isAbsolute(requested)
|| requested.includes('/')
|| requested.includes('\\');
const extensions = executableExtensions(requested, env, platform);
const candidates = [];
if (pathLike) {
const base = pathApi.isAbsolute(requested) ? requested : pathApi.resolve(cwd, requested);
for (const extension of extensions) candidates.push(base + extension);
} else {
const pathValue = env.PATH || env.Path || env.path
|| (platform === 'win32' ? '' : '/usr/bin:/bin');
for (const rawEntry of String(pathValue).split(pathApi.delimiter)) {
const entry = rawEntry.replace(/^"|"$/g, '') || cwd;
for (const extension of extensions) candidates.push(pathApi.join(entry, requested + extension));
}
}
for (const candidate of candidates) {
try {
fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
if (!fs.statSync(candidate).isFile()) continue;
return { available: true, command: requested, resolvedPath: candidate };
} catch {
// Keep searching PATH. Shell aliases are intentionally ignored because
// child_process.spawn cannot resolve them either.
}
}
return { available: false, error: 'codex_cli_unavailable', command: requested };
}
function executableExtensions(command, env, platform) {
if (platform !== 'win32') return [''];
if (path.win32.extname(command)) return [''];
const value = env.PATHEXT || env.Pathext || '.COM;.EXE;.BAT;.CMD';
return String(value)
.split(';')
.map((extension) => extension.trim())
.filter(Boolean)
.map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
}
export function isCodexRuntime(env = process.env) {
return Boolean(
nonEmpty(env.CODEX_THREAD_ID)