diff --git a/site/components/LiveUiGallery.astro b/site/components/LiveUiGallery.astro index 94dad57b5..f15b42c03 100644 --- a/site/components/LiveUiGallery.astro +++ b/site/components/LiveUiGallery.astro @@ -8,6 +8,7 @@ const STATE_GROUPS = [ states: [ ['global-ready', 'Ready'], ['global-disconnected', 'Agent disconnected'], + ['global-foreground', 'Codex CLI fallback'], ['global-tools', 'Detect + DESIGN.md'], ['steer-expanded', 'Steer composing'], ['steer-processing', 'Steer processing'], diff --git a/site/scripts/live-ui-gallery.js b/site/scripts/live-ui-gallery.js index 63a942b37..50826d961 100644 --- a/site/scripts/live-ui-gallery.js +++ b/site/scripts/live-ui-gallery.js @@ -257,7 +257,7 @@ function designPanel(tab = 'visual') { `; } -function globalBar({ connected = true, active = 'pick', steer = 'collapsed', detectCount = 0, designActive = false } = {}) { +function globalBar({ connected = true, workerFallback = false, active = 'pick', steer = 'collapsed', detectCount = 0, designActive = false } = {}) { const modeButton = (key, icon, label, target, extra = '') => { const isActive = active === key || (key === 'design' && designActive); return ``; @@ -270,8 +270,8 @@ function globalBar({ connected = true, active = 'pick', steer = 'collapsed', det return `
- - ${brandMark()}${connected ? '' : ''} + + ${brandMark()}${connected && !workerFallback ? '' : ''}
${modeButton('pick', ICONS.pick, 'Pick', 'configure-replace')} @@ -291,6 +291,10 @@ function sceneFor(state, context) { switch (state) { case 'global-disconnected': return hostPage() + '' + commonGlobal({ connected: false }); + case 'global-foreground': + return hostPage() + + '
Codex CLI is unavailable. Live is using the main agent, so generation may take longer. Install the CLI, run codex login, then restart Live.
' + + commonGlobal({ workerFallback: true }); case 'global-tools': return hostPage() + commonGlobal({ active: 'detect', detectCount: 7, designActive: true }); case 'steer-expanded': diff --git a/site/styles/live-ui-gallery.css b/site/styles/live-ui-gallery.css index 515f3ac82..a45e73932 100644 --- a/site/styles/live-ui-gallery.css +++ b/site/styles/live-ui-gallery.css @@ -1132,6 +1132,10 @@ button.lvg-dot { color: oklch(62% 0 0 / 0.78); } +.lvg-live-brand[data-worker-fallback="true"] .lvg-agent-dot { + animation: none; +} + .lvg-agent-dot { position: absolute; right: 4px; diff --git a/skill/reference/live.md b/skill/reference/live.md index a2db88191..329672026 100644 --- a/skill/reference/live.md +++ b/skill/reference/live.md @@ -48,6 +48,8 @@ Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, pro If output includes `codexWorker.enabled: true`, run the returned `codexWorker.foregroundPoll` command. The dedicated lane owns `generate,accept,discard,prefetch`; the foreground owns `steer,manual_edit_apply,carbonize_cleanup,exit`. The fallback flag restores generation to the foreground only when the worker's owned process record is failed or unreachable. After each event, restart that same command. Do not also run the default unfiltered poll. +If output includes `codexWorker.error: "codex_cli_unavailable"`, tell the user once that Live is using foreground generation, then run the returned unfiltered `codexWorker.foregroundPoll`. Do not retry or install anything during the session. The browser mark carries a static status dot and explains that installing Codex CLI, running `codex login`, and restarting Live enables background variants. + `serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname). If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom. @@ -118,7 +120,7 @@ Activation is process-local: the worker is enabled by default only when the proc } ``` -The app-server worker is **default-on in Codex and Codex-only**. Claude, Gemini, Cursor, and every other harness keep the portable foreground/atomic behavior. Live records the worker as `starting` and returns immediately, so app-server initialization overlaps page/dev-server startup. Run only the returned foreground control poll. It checks the owned worker process every two seconds and safely restores generation/accept/discard leasing if startup, authentication, model selection, or the worker process fails. Dedicated-worker leases expire after 15 seconds, so a hard process loss cannot strand browser work behind the portable ten-minute lease. +The app-server worker is **default-on in Codex and Codex-only**. Claude, Gemini, Cursor, and every other harness keep the portable foreground/atomic behavior. Before detaching anything, Live resolves the configured Codex executable using the same explicit-path/PATH rules as Node spawn. A missing CLI becomes an immediate, durable foreground fallback with setup guidance instead of a misleading prewarm state. Otherwise Live records the worker as `starting` and returns immediately, so app-server initialization overlaps page/dev-server startup. Run only the returned foreground control poll. It checks the owned worker process every two seconds and safely restores generation/accept/discard leasing if startup, authentication, model selection, or the worker process fails. Dedicated-worker leases expire after 15 seconds, so a hard process loss cannot strand browser work behind the portable ten-minute lease. ```bash node {{scripts_path}}/live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit --codex-worker-fallback diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 7f8c7ec00..ffc79895f 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -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() { `; } - 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 */ }); } diff --git a/skill/scripts/live-codex-worker.mjs b/skill/scripts/live-codex-worker.mjs index dc5e8f10f..c6a6e9162 100644 --- a/skill/scripts/live-codex-worker.mjs +++ b/skill/scripts/live-codex-worker.mjs @@ -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'); diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index b4e304899..fa75d05e8 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -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'); diff --git a/skill/scripts/live-status.mjs b/skill/scripts/live-status.mjs index f7e464999..c6f82e58a 100644 --- a/skill/scripts/live-status.mjs +++ b/skill/scripts/live-status.mjs @@ -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 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 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; diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 8cf689cf1..dfe620d3c 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -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 { diff --git a/skill/scripts/live/codex-worker.mjs b/skill/scripts/live/codex-worker.mjs index 00164d65e..a7e8954ac 100644 --- a/skill/scripts/live/codex-worker.mjs +++ b/skill/scripts/live/codex-worker.mjs @@ -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) diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 395092703..8518f0e13 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -8,6 +8,24 @@ const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\ const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || ''; describe('live-browser source contracts', () => { + it('surfaces missing Codex CLI fallback without treating the agent as disconnected', () => { + assert.match( + SOURCE, + /syncAgentPollingUi\(!!msg\.agentPolling, msg\.codexWorker\)/, + 'the initial SSE state should include the dedicated worker status', + ); + assert.match( + SOURCE, + /cliUnavailable = codexWorkerStatus\?\.error === 'codex_cli_unavailable'[\s\S]*?using foreground generation/, + 'a missing CLI should keep Live usable while exposing foreground mode accessibly', + ); + assert.match( + SOURCE, + /Codex CLI is unavailable\. Live is using the main agent, so generation may take longer\.[\s\S]*?codex login/, + 'the one-time fallback notice should explain the performance impact and recovery action', + ); + }); + it('routes Nuxt Vue preview modules through the Vite build-assets base', () => { assert.match( SOURCE, diff --git a/tests/live-codex-worker.test.mjs b/tests/live-codex-worker.test.mjs index 1c53db282..e757ca2ee 100644 --- a/tests/live-codex-worker.test.mjs +++ b/tests/live-codex-worker.test.mjs @@ -16,6 +16,7 @@ import { codexWorkerStateIsOwned, isCodexRuntime, readPreparedArtifact, + resolveCodexExecutable, resolveCodexWorkerConfig, } from '../skill/scripts/live/codex-worker.mjs'; @@ -62,6 +63,31 @@ describe('Codex Live worker configuration', () => { assert.equal(codexWorkerStateIsOwned({ owner: CODEX_WORKER_OWNER, cwd, pid: 123, status: 'starting' }, cwd), false); }); + it('resolves configured Codex executables without spawning a preflight process', () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-path-')); + const bin = path.join(cwd, 'bin'); + mkdirSync(bin); + const executable = path.join(bin, 'codex'); + writeFileSync(executable, '#!/bin/sh\nexit 0\n'); + chmodSync(executable, 0o755); + + assert.deepEqual(resolveCodexExecutable('./bin/codex', { cwd, env: {} }), { + available: true, + command: './bin/codex', + resolvedPath: executable, + }); + assert.deepEqual(resolveCodexExecutable('codex', { cwd, env: { PATH: bin } }), { + available: true, + command: 'codex', + resolvedPath: executable, + }); + assert.deepEqual(resolveCodexExecutable('missing-codex', { cwd, env: { PATH: bin } }), { + available: false, + error: 'codex_cli_unavailable', + command: 'missing-codex', + }); + }); + it('leaves the portable foreground path untouched when the switch is off', () => { const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-disabled-')); const script = path.resolve('skill/scripts/live-codex-worker.mjs'); @@ -78,6 +104,69 @@ describe('Codex Live worker configuration', () => { }); }); + it('reports a missing Codex CLI immediately and records actionable foreground fallback', () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-missing-cli-')); + const script = path.resolve('skill/scripts/live-codex-worker.mjs'); + const missing = path.join(cwd, 'not-installed', 'codex'); + const result = spawnSync(process.execPath, [script, '--background', '--no-wait'], { + cwd, + encoding: 'utf-8', + env: { + ...process.env, + IMPECCABLE_LIVE_CODEX_WORKER: '1', + IMPECCABLE_CODEX_PATH: missing, + }, + }); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.ok, false); + assert.equal(output.status, 'unavailable'); + assert.equal(output.error, 'codex_cli_unavailable'); + assert.equal(output.fallback, 'foreground'); + assert.equal(output.pid, null); + assert.equal(output.setup.afterInstall, 'codex login'); + + const state = JSON.parse(readFileSync(path.join(cwd, '.impeccable/live/codex-worker.json'), 'utf-8')); + assert.equal(state.error, 'codex_cli_unavailable'); + assert.equal(state.mode, 'foreground'); + assert.equal(state.command, missing); + }); + + it('reuses an owned worker before checking whether Codex is still on PATH', () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-reuse-')); + const statePath = path.join(cwd, '.impeccable/live/codex-worker.json'); + mkdirSync(path.dirname(statePath), { recursive: true }); + const worker = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { + stdio: 'ignore', + }); + try { + writeFileSync(statePath, JSON.stringify({ + owner: CODEX_WORKER_OWNER, + cwd, + threadId: 'owned-thread', + pid: worker.pid, + status: 'ready', + })); + const script = path.resolve('skill/scripts/live-codex-worker.mjs'); + const result = spawnSync(process.execPath, [script, '--background', '--no-wait'], { + cwd, + encoding: 'utf-8', + env: { + ...process.env, + IMPECCABLE_LIVE_CODEX_WORKER: '1', + IMPECCABLE_CODEX_PATH: path.join(cwd, 'missing-codex'), + }, + }); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.ok, true); + assert.equal(output.reused, true); + assert.equal(output.pid, worker.pid); + } finally { + worker.kill('SIGTERM'); + } + }); + it('refuses to signal a pid from an unowned state record', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-unowned-')); const statePath = path.join(cwd, '.impeccable/live/codex-worker.json'); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 249c5fa55..8ca7c905a 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawn } from 'node:child_process'; import { getDesignSidecarPath, + getLiveCodexWorkerStatePath, getLiveDir, getLiveServerPath, getLiveSessionsDir, @@ -225,6 +226,37 @@ describe('live-server integration', () => { await drainPolls(server); }); + it('/status exposes a safe actionable Codex foreground fallback', async () => { + const workerPath = getLiveCodexWorkerStatePath(serverCwd); + mkdirSync(join(serverCwd, '.impeccable', 'live'), { recursive: true }); + writeFileSync(workerPath, JSON.stringify({ + owner: 'impeccable-live-codex-worker-v1', + cwd: serverCwd, + pid: null, + status: 'unavailable', + mode: 'foreground', + error: 'codex_cli_unavailable', + command: 'codex', + stack: 'must not cross the status boundary', + setup: { + docsUrl: 'https://learn.chatgpt.com/docs/codex/cli', + afterInstall: 'codex login', + }, + })); + try { + const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.codexWorker.status, 'unavailable'); + assert.equal(data.codexWorker.mode, 'foreground'); + assert.equal(data.codexWorker.error, 'codex_cli_unavailable'); + assert.equal(data.codexWorker.setup.afterInstall, 'codex login'); + assert.equal(data.codexWorker.stack, undefined); + } finally { + rmSync(workerPath, { force: true }); + } + }); + it('/status reports agentPolling from active poll leases', async () => { await drainPolls(server); let res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`);