mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 02:26:31 +03:00
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:
@@ -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 */ });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user