From 8ab578fd3b97ceb654dcaa19ecc1b8c85faf9e48 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 11:02:50 +0530 Subject: [PATCH 1/7] fix(ai): keep sidebar scrollbars dark on Windows hover Give the AI chat, Home and history scrollers the same scoped styling as Layers: a transparent track and a subtle rounded thumb. The Windows light-theme scrollbar hover rule previously painted a white strip inside the always-dark AI sidebar. Keep the override confined to these sidebar scrollers so editor and nested tool scrollbars retain their own styles. Reproduced and verified with real mouse hover in the Windows debug app. Checked native macOS light/dark computed styles and matching scrollbar styling in Linux. LESS compilation and whitespace checks pass. No test suites were added or run for this CSS-only change. --- src/styles/Extn-AIChatPanel.less | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 1727bff072..6e9defe100 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -138,6 +138,26 @@ color: @project-panel-text-1; font-size: @ai-text-body; container-type: inline-size; + + // Match Layers' dark sidebar scrollbars, including Windows light-theme hover. + #sidebar & { + .ai-chat-messages, + .ai-chat-start, + .ai-session-history-dropdown { + &::-webkit-scrollbar { + width: 9px; + height: 9px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.12); + background-clip: padding-box; + border: 2px solid transparent; + } + } + } } /* ── Header ─────────────────────────────────────────────────────────── */ From a9189c56d84ba67a4d9526042ebdb7a5005abf7e Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 11:22:52 +0530 Subject: [PATCH 2/7] fix(ai): clarify history navigation and selected state Show Back whenever AI History is open, including when opened from Home. Highlight the active History button with the existing AI accent and keep header actions visible while History is open. Show the History heading in place of the mode selector and hide the chat model selector. Closing History restores the original heading and model control and clears the selected button state. Verified manually in the connected Linux app and with all nine cases in the Pro AI Chat History Navigation integration suite. LESS compilation, ESLint, and whitespace checks pass. Leave Pro pins unchanged while further changes are pending, as requested. --- src/styles/Extn-AIChatPanel.less | 37 +++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 6e9defe100..ca3f3b0346 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -193,7 +193,8 @@ } } - .ai-chat-title { + .ai-chat-title, + .ai-chat-history-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -202,6 +203,10 @@ color: inherit; } + .ai-chat-history-title { + display: none; + } + .ai-chat-header-actions { display: flex; align-items: center; @@ -301,6 +306,12 @@ cursor: pointer; } + .ai-history-btn.active { + color: @ai-start-accent; + background-color: fade(@ai-start-accent, 15%); + border-color: fade(@ai-start-accent, 35%); + } + .ai-new-session-btn, .ai-stop-cli-btn { display: flex; @@ -388,10 +399,29 @@ } } -.ai-chat-panel.ai-chat-started:not(.ai-mode-cli):not(.ai-home) .ai-back-btn { +.ai-chat-panel.ai-chat-started:not(.ai-mode-cli):not(.ai-home) .ai-back-btn, +.ai-chat-panel.ai-history-open .ai-back-btn { display: flex; } +.ai-chat-panel.ai-history-open .ai-chat-title-group { + pointer-events: none; + + .ai-chat-title, + .ai-chat-title-icon, + .ai-chat-title-chevron { + display: none; + } + + .ai-chat-history-title { + display: inline; + } +} + +.ai-chat-panel.ai-history-open .ai-model-select { + display: none; +} + /* Home: the start screen laid over a chat that has started. The chat keeps running underneath, its messages and input out of sight until it is picked again. */ @@ -727,9 +757,10 @@ } } -/* Show header actions on tab container hover */ +/* Keep header actions visible while History or connection details are open. */ .ai-tab-container:hover .ai-chat-header-actions, .ai-chat-header:focus-within .ai-chat-header-actions, +.ai-chat-panel.ai-history-open .ai-chat-header-actions, .ai-chat-header.ai-cli-connection-open .ai-chat-header-actions { opacity: 1; pointer-events: auto; From 8bcff0d55af8d1d4f4528f04df71acd651945f1c Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 14:25:33 +0530 Subject: [PATCH 3/7] docs(storage): retain synchronous LMDB validation after benchmarking Keep the original storage implementation and record the rejected async experiment beside execFileSync. App-ready was slower in 7/10 Windows, 6/10 Linux and 8/15 Mac matched warm-reload pairs despite better Node responsiveness. Link the preserved benchmark baseline and raw results. All experimental runtime and test changes were reverted. Existing storage integration tests pass 11/11 on Windows, Mac and Linux after restoration. --- src-node/lmdb.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-node/lmdb.js b/src-node/lmdb.js index 518a21e104..e2d0112b4d 100644 --- a/src-node/lmdb.js +++ b/src-node/lmdb.js @@ -38,6 +38,11 @@ function _validateDBIntegrity(lmdbDir) { db.getKeys({ limit: 1 }).next(); db.close(); `; + // Keep this synchronous: the 2026-10-25 async experiment had slower app-ready + // in 7/10 Windows, 6/10 Linux and 8/15 Mac matched warm app-reload pairs. + // Better Node responsiveness did not meet the startup requirement, so it was rejected. + // Baseline and raw results: src/extensionsIntegrated/phoenix-pro/docs/ + // boot-01-startup-benchmark-2026-10-25.json (BOOT-01). execFileSync(process.execPath, ["-e", script], { timeout: 10000, stdio: "pipe", From bee4bcf8e474f115879af1a53f171a1fceab8942 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 15:09:25 +0530 Subject: [PATCH 4/7] perf(cli): make executable discovery asynchronous Replace blocking where/which commands and filesystem probes with bounded execFile lookups and asynchronous access/nvm metadata reads. Check trusted native install paths before starting PATH discovery, retain candidate and override precedence, and share in-flight work by CLI plus override. Await downloader detection at the Node RPC boundary so setup receives the resolved tool name. Keep the synchronous LMDB integrity check unchanged. Extend the registered Jasmine CLI Locator suite for parent responsiveness while a real child waits, lookup fallback/deadlines, native-first discovery, and concurrent overrides. Native runs pass 21/21 on Linux and macOS and 19/19 on Windows; the two additional POSIX fixtures do not run on Windows. The companion Phoenix Pro change defers discovery until first AI use and records the full cross-platform startup benchmark and its limitations. --- src-node/claude-code-agent.js | 2 +- src-node/cli-locator.js | 176 +++++++++++++++++------------- src-node/test/test-cli-locator.js | 124 ++++++++++++++++++--- test/spec/CLILocator-test.js | 34 ++++++ 4 files changed, 239 insertions(+), 97 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ea92907003..64523ab0b6 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -799,7 +799,7 @@ exports.validateCliPath = async function (params) { * @return {Promise<{downloader: ?string}>} "curl", "wget", or null */ exports.getInstallDownloader = async function () { - return { downloader: CliLocator.findDownloader() }; + return { downloader: await CliLocator.findDownloader() }; }; /** diff --git a/src-node/cli-locator.js b/src-node/cli-locator.js index 088912d1f5..af9ff8dd40 100644 --- a/src-node/cli-locator.js +++ b/src-node/cli-locator.js @@ -32,15 +32,14 @@ * and known locations, each proved by actually running `--version`. */ -const { execSync, spawn } = require("child_process"); +const { execFile, spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); const isWindows = process.platform === "win32"; -// PATH lookups (`where`/`which`) run synchronously and block this whole Node -// process — the integrated terminal, file watchers and MCP all share it. An -// unreachable network drive on PATH can wedge them, so cap the wait. +// Bound asynchronous PATH lookups: an unreachable network drive must not +// leave discovery pending indefinitely or block the shared Node event loop. const PATH_LOOKUP_TIMEOUT_MS = 3000; // How long a `--version` probe may take before we give up on a candidate. @@ -102,11 +101,11 @@ const CLI_REGISTRY = { posixNative: function (home) { return [path.join(home, ".local", "bin", "claude")]; // claude.ai/install.sh default }, - posixExtra: function (home) { + posixExtra: async function (home) { return [ "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew "/usr/bin/claude", // Distro package - ..._nvmCandidates(home, "claude"), // npm global via nvm + ...await _nvmCandidates(home, "claude"), // npm global via nvm "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew ]; @@ -147,11 +146,11 @@ const CLI_REGISTRY = { path.join(home, ".codex", "packages", "standalone", "current", "bin", "codex") ]; }, - posixExtra: function (home) { + posixExtra: async function (home) { return [ "/usr/local/bin/codex", "/usr/bin/codex", - ..._nvmCandidates(home, "codex"), + ...await _nvmCandidates(home, "codex"), "/opt/homebrew/bin/codex", // brew install --cask codex "/home/linuxbrew/.linuxbrew/bin/codex" ]; @@ -172,7 +171,7 @@ let _bundledClaude = null; // Promise: looked up once per // cliId -> { path, at, overrideSig, source, version, errorCode, override, searched } const _cache = new Map(); -// cliId -> in-flight discovery promise, so concurrent callers share one walk +// cliId + override signature -> in-flight discovery promise, sharing one walk // of the fallback chain instead of each spawning their own --version probes. const _inFlight = new Map(); // cliId -> user-configured override path ("" when unset) @@ -187,29 +186,28 @@ const _overrides = new Map(); * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest * first, so we still find the CLI when the default alias is a label like * `lts/*` or `node` that we don't expand here. + * @param {string} home - User's home directory. + * @param {string} bin - CLI executable name. + * @return {Promise>} Candidate paths in preference order. */ -function _nvmCandidates(home, bin) { +async function _nvmCandidates(home, bin) { const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); const versionsDir = path.join(nvmRoot, "versions", "node"); const candidates = []; try { const aliasFile = path.join(nvmRoot, "alias", "default"); - if (fs.existsSync(aliasFile)) { - const alias = fs.readFileSync(aliasFile, "utf8").trim(); - if (/^v?\d/.test(alias)) { - const v = alias.startsWith("v") ? alias : "v" + alias; - candidates.push(path.join(versionsDir, v, "bin", bin)); - } + const alias = (await fs.promises.readFile(aliasFile, "utf8")).trim(); + if (/^v?\d/.test(alias)) { + const v = alias.startsWith("v") ? alias : "v" + alias; + candidates.push(path.join(versionsDir, v, "bin", bin)); } } catch { /* nvm not installed or unreadable */ } try { - if (fs.existsSync(versionsDir)) { - const versions = fs.readdirSync(versionsDir) - .filter(v => /^v\d/.test(v)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions) { - candidates.push(path.join(versionsDir, v, "bin", bin)); - } + const versions = (await fs.promises.readdir(versionsDir)) + .filter(v => /^v\d/.test(v)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions) { + candidates.push(path.join(versionsDir, v, "bin", bin)); } } catch { /* ignore */ } return candidates; @@ -236,18 +234,27 @@ function _dedupe(paths) { /** * Ask the OS where `bin` lives. Returns [] when the lookup tool is missing, * finds nothing, or takes too long. + * @param {string} bin - Executable name to find. + * @return {Promise>} Matching executable paths in preference order. */ -function _pathLookup(bin) { +async function _pathLookup(bin) { try { - const cmd = isWindows - ? "where " + bin - : "which -a " + bin + " 2>/dev/null || which " + bin; - const out = execSync(cmd, { - encoding: "utf8", - timeout: PATH_LOOKUP_TIMEOUT_MS, - windowsHide: true - }).trim(); - let paths = out.split(isWindows ? "\r\n" : "\n") + const started = Date.now(); + const lookup = (args, timeout) => new Promise(resolve => { + execFile(isWindows ? "where.exe" : "which", args, { + encoding: "utf8", timeout, windowsHide: true, env: process.env + }, (error, stdout) => resolve({error, stdout})); + }); + let result = await lookup(isWindows ? [bin] : ["-a", bin], PATH_LOOKUP_TIMEOUT_MS); + const remaining = PATH_LOOKUP_TIMEOUT_MS - (Date.now() - started); + // Some which implementations do not support -a. Retain the single-match + // fallback without retrying a timeout or exceeding the original deadline. + if (!isWindows && result.error && !result.error.killed && + typeof result.error.code === "number" && remaining > 0) { + result = await lookup([bin], remaining); + } + if (result.error) { return []; } + let paths = (result.stdout || "").trim().split(/\r?\n/) .map(p => p.trim()) .filter(p => p && !p.includes("node_modules")); if (isWindows) { @@ -266,41 +273,43 @@ function _pathLookup(bin) { } /** - * Ordered candidate paths for a CLI, split into two tiers: - * - `native`: installers known to drop a real executable. No shim chain to - * break, so file existence is enough confidence — we skip `--version`. - * - `fallback`: PATH discovery and known locations. Broken installs are - * common here (an orphan `.cmd` whose cli.js got deleted), so every - * candidate is proved by running `--version` before we return it. + * Installer locations that can be checked before starting PATH discovery. * @param {Object} cli - a CLI_REGISTRY entry - * @return {{native: Array, fallback: Array}} + * @return {Array} Native candidates in preference order. + */ +function _nativeCandidates(cli) { + const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; + return _dedupe(isWindows ? cli.winNative() : cli.posixNative(home)); +} + +/** + * Find PATH and other fallback candidates only after native candidates fail. + * @param {Object} cli - CLI registry entry. + * @param {Array} nativePaths - Previously considered native paths. + * @return {Promise>} Fallback paths, each requiring a version probe. */ -function _candidates(cli) { +async function _fallbackCandidates(cli, nativePaths) { const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; - const native = isWindows ? cli.winNative() : cli.posixNative(home); - const extra = isWindows ? cli.winExtra() : cli.posixExtra(home); - const nativePaths = _dedupe(native); + const [onPath, extra] = await Promise.all([ + _pathLookup(cli.bin), isWindows ? cli.winExtra() : cli.posixExtra(home) + ]); // Dedupe the fallback tier against native too: `which` reports the same // file the native tier already listed, and a user reading searchedPaths // should not see it twice. const seenNative = new Set(nativePaths.map(p => (isWindows ? p.toLowerCase() : p))); - const fallback = _dedupe([..._pathLookup(cli.bin), ...extra]) + return _dedupe([...onPath, ...extra]) .filter(p => !seenNative.has(isWindows ? p.toLowerCase() : p)); - return { native: nativePaths, fallback: fallback }; } /** - * Existence + executability check. On Windows executability is derived from - * extension/PATHEXT not a file attribute, so existsSync is the right test; - * on posix we want the +x bit. + * Check existence on Windows and executable permission on POSIX without blocking Node. + * @param {string} p - Candidate path. + * @return {Promise} Whether the candidate can be used. */ -function canAccess(p) { +async function canAccess(p) { if (!p) { return false; } try { - if (isWindows) { - return fs.existsSync(p); - } - fs.accessSync(p, fs.constants.X_OK); + await fs.promises.access(p, isWindows ? fs.constants.F_OK : fs.constants.X_OK); return true; } catch { return false; @@ -454,16 +463,17 @@ async function _resolveOverride(cli, override) { // affordance the Git extension's gitPath setting allows. let resolved = raw; if (!raw.includes("/") && !raw.includes("\\")) { - const found = _pathLookup(raw)[0]; + const found = (await _pathLookup(raw))[0]; if (!found) { return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_MISSING, onPath: true }; } resolved = found; } - if (!canAccess(resolved)) { + if (!await canAccess(resolved)) { // Split "no such file" from "there but not executable": the latter is // the common chmod mistake and deserves its own advice. - const code = (!isWindows && fs.existsSync(resolved)) + const exists = !isWindows && await fs.promises.access(resolved).then(() => true, () => false); + const code = exists ? ERROR_CODES.OVERRIDE_NOT_EXECUTABLE : ERROR_CODES.OVERRIDE_MISSING; return { used: true, valid: false, path: resolved, errorCode: code }; @@ -539,7 +549,7 @@ function _locateBundledClaude() { _bundledClaude = (async function () { const cli = CLI_REGISTRY.claude; for (const p of _bundledClaudeCandidates()) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } const probe = await _probeVersion(cli, p); if (probe.ok) { console.log("[Phoenix AI] Bundled claude CLI at:", p, probe.version); @@ -584,14 +594,20 @@ function setBundledRoot(dir) { _cache.delete("claude"); } -function _cacheHit(cliId, overrideSig) { +/** + * Check that a cached result still matches this request and its executable exists. + * @param {string} cliId - CLI registry ID. + * @param {string} overrideSig - Trimmed override for this request. + * @return {Promise} A matching, usable cache entry. + */ +async function _cacheHit(cliId, overrideSig) { const entry = _cache.get(cliId); if (!entry || entry.overrideSig !== overrideSig) { return null; } - if (entry.path !== null && !canAccess(entry.path)) { + if (entry.path !== null && !await canAccess(entry.path)) { console.log("[Phoenix AI] " + cliId + " CLI is no longer at", entry.path); - _cache.delete(cliId); + if (_cache.get(cliId) === entry) { _cache.delete(cliId); } return null; } const fresh = entry.path !== null || (Date.now() - entry.at) < NULL_CACHE_TTL_MS; @@ -631,19 +647,20 @@ function locateCli(cliId, opts) { const force = !!(opts && opts.force); const override = (opts && opts.override !== undefined) ? opts.override : getOverride(cliId); const overrideSig = (override || "").trim(); + const pendingKey = cliId + ":" + overrideSig; if (!force) { - const hit = _cacheHit(cliId, overrideSig); - if (hit) { - return Promise.resolve(_toResult(cliId, hit)); - } - const pending = _inFlight.get(cliId); + const pending = _inFlight.get(pendingKey); if (pending) { return pending; } } const discovery = (async function () { + if (!force) { + const hit = await _cacheHit(cliId, overrideSig); + if (hit) { return _toResult(cliId, hit); } + } const entry = { path: null, at: Date.now(), overrideSig, searched: [] }; if (overrideSig) { @@ -668,8 +685,8 @@ function locateCli(cliId, opts) { return _toResult(cliId, entry); } - const { native, fallback } = _candidates(cli); - entry.searched = [...native, ...fallback]; + const native = _nativeCandidates(cli); + entry.searched = native.slice(); // Only claude ships with Phoenix. With a bundled copy to weigh a // system install against, the system install's version has to be // known, so the native tier is probed too. @@ -679,7 +696,7 @@ function locateCli(cliId, opts) { } let found = null; // {path, source, version} for (const p of native) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } let version = null; if (bundled) { const probe = await _probeVersion(cli, p); @@ -694,8 +711,10 @@ function locateCli(cliId, opts) { break; } if (!found) { + const fallback = await _fallbackCandidates(cli, native); + entry.searched.push(...fallback); for (const p of fallback) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } const probe = await _probeVersion(cli, p); if (probe.ok) { console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p); @@ -729,12 +748,13 @@ function locateCli(cliId, opts) { }()); if (!force) { - _inFlight.set(cliId, discovery); - discovery.finally(function () { - if (_inFlight.get(cliId) === discovery) { - _inFlight.delete(cliId); + _inFlight.set(pendingKey, discovery); + const clearPending = function () { + if (_inFlight.get(pendingKey) === discovery) { + _inFlight.delete(pendingKey); } - }); + }; + discovery.then(clearPending, clearPending); } return discovery; } @@ -769,11 +789,11 @@ const DOWNLOADERS = ["curl", "wget"]; /** * Which downloader is on PATH, so the install command offered to the user is * one that can actually run. - * @return {?string} "curl", "wget", or null when neither is installed + * @return {Promise} "curl", "wget", or null when neither is installed */ -function findDownloader() { +async function findDownloader() { for (const bin of DOWNLOADERS) { - if (_pathLookup(bin).length) { + if ((await _pathLookup(bin)).length) { return bin; } } diff --git a/src-node/test/test-cli-locator.js b/src-node/test/test-cli-locator.js index 1dd8a4a3d9..1ff544c79b 100644 --- a/src-node/test/test-cli-locator.js +++ b/src-node/test/test-cli-locator.js @@ -18,7 +18,7 @@ const PROCESS_TIMEOUT_MS = 3000; * @param {string} platform - Node platform name * @param {Object} env - Simulated desktop environment * @param {Function} [spawnImpl] - Optional probe process substitute - * @param {{fs: Object, execSync: Function}} [stubs] - Optional file system and PATH lookup substitutes + * @param {{fs: Object, execFile: Function}} [stubs] - Optional file system and PATH lookup substitutes * @return {Object} Locator exports */ function _loadLocator(platform, env, spawnImpl, stubs) { @@ -27,8 +27,8 @@ function _loadLocator(platform, env, spawnImpl, stubs) { if (spawnImpl) { processStubs.spawn = spawnImpl; } - if (stubs && stubs.execSync) { - processStubs.execSync = stubs.execSync; + if (stubs && stubs.execFile) { + processStubs.execFile = stubs.execFile; } const dependencies = { path: platform === "win32" ? path.win32 : path.posix, @@ -177,13 +177,22 @@ async function runStandaloneFixture() { async function locateWindowsStandaloneCodex() { const env = { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", USERPROFILE: "C:\\Users\\me" }; const installedPath = "C:\\Users\\me\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; + let pathLookups = 0; const locator = _loadLocator("win32", env, null, { fs: Object.assign({}, fs, { - existsSync: function (candidate) { return candidate === installedPath; } + promises: Object.assign({}, fs.promises, { + access: async function (candidate) { + if (candidate !== installedPath) { throw new Error("ENOENT"); } + } + }) }), - execSync: function () { throw new Error("not on PATH"); } + execFile: function (_command, _args, _options, callback) { + pathLookups++; + callback(new Error("not on PATH"), ""); + } }); - return { located: await locator.locateCli("codex"), installedPath }; + const located = await locator.locateCli("codex"); + return { located, installedPath, pathLookups }; } /** @@ -212,12 +221,17 @@ function _bundledLocator(versions) { : "process.exit(1)"]); }, { fs: Object.assign({}, fs, { - accessSync: function (candidate) { - if (!versions[candidate]) { throw new Error("ENOENT"); } - }, - existsSync: function (candidate) { return !!versions[candidate]; } + promises: { + access: async function (candidate) { + if (!versions[candidate]) { throw new Error("ENOENT"); } + }, + readFile: async function () { throw new Error("ENOENT"); }, + readdir: async function () { return []; } + } }), - execSync: function () { throw new Error("not on PATH"); } + execFile: function (_command, _args, _options, callback) { + callback(new Error("not on PATH"), ""); + } }); locator.setBundledRoot(BUNDLED_ROOT); return locator; @@ -256,20 +270,94 @@ async function relocateAfterRemoval() { * @return {Promise>} Chosen downloader per scenario */ async function findDownloaders({installed}) { - return installed.map(function (tools) { + return Promise.all(installed.map(function (tools) { const locator = _loadLocator("linux", { PATH: "/usr/bin" }, null, { - execSync: function (command) { - const found = tools.find(function (tool) { return command.endsWith("which " + tool); }); - if (!found) { - throw new Error("not on PATH"); - } - return "/usr/bin/" + found + "\n"; + execFile: function (_command, args, _options, callback) { + const found = tools.find(function (tool) { return args[args.length - 1] === tool; }); + callback(found ? null : new Error("not on PATH"), found ? "/usr/bin/" + found + "\n" : ""); } }); return locator.findDownloader(); + })); +} + +/** + * Hold a real lookup child on stdin while the parent services a file read and timer. + * @return {Promise} Responsiveness and bounded, shell-less lookup options. + */ +async function lookupWhileBusy() { + let child, options, lookupStarted; + const started = new Promise(resolve => { lookupStarted = resolve; }); + const locator = _loadLocator("linux", { PATH: "/usr/bin" }, null, { + execFile: function (_command, _args, opts, callback) { + options = opts; + child = childProcess.execFile(process.execPath, + ["-e", "process.stdin.once('data', () => { console.log('/usr/bin/curl'); process.exit(); });"], + { timeout: PROCESS_TIMEOUT_MS }, callback); + lookupStarted(); + return child; + } + }); + let settled = false; + const lookup = locator.findDownloader().then(result => { settled = true; return result; }); + try { + await started; + const source = await fs.promises.readFile(__filename, "utf8"); + await new Promise(resolve => setImmediate(resolve)); + const pendingDuringRead = !settled; + child.stdin.end("finish"); + return { downloader: await lookup, pendingDuringRead, readCompleted: source.length > 0, + timeout: options.timeout, shell: !!options.shell, windowsHide: options.windowsHide }; + } finally { + if (child) { child.kill(); } + } +} + +/** + * Simulate which variants or a failed lookup without depending on host-installed tools. + * @param {{failure: string}} params - unsupported, timeout, or missing. + * @return {Promise} Downloader choice and lookup arguments/deadlines. + */ +async function lookupFailure({failure}) { + const calls = []; + const locator = _loadLocator("linux", {}, null, { + execFile: function (command, args, options, callback) { + calls.push({command, args, timeout: options.timeout}); + if (failure === "unsupported" && args.length === 1) { + callback(null, "/usr/bin/curl\n"); + } else { + const error = new Error(failure); + error.code = failure === "missing" ? "ENOENT" : 1; + error.killed = failure === "timeout"; + callback(error, ""); + } + } }); + return { downloader: await locator.findDownloader(), calls }; +} + +/** + * Concurrent callers share the same override, while different overrides stay independent. + * @return {Promise} Promise identity and located paths. + */ +async function concurrentOverrides() { + const paths = ["/test/one/codex", "/test/two/codex"]; + const locator = _loadLocator("linux", {}, function () { + return childProcess.spawn(process.execPath, ["-e", "console.log('codex-cli 1.0.0');"]); + }, { fs: Object.assign({}, fs, { promises: { + access: async function (candidate) { + if (!paths.includes(candidate)) { throw new Error("ENOENT"); } + } + } }) }); + const first = locator.locateCli("codex", {override: paths[0]}); + const shared = locator.locateCli("codex", {override: paths[0]}); + const second = locator.locateCli("codex", {override: paths[1]}); + return { shared: first === shared, independent: first !== second, results: await Promise.all([first, second]) }; } +exports.lookupWhileBusy = lookupWhileBusy; +exports.lookupFailure = lookupFailure; +exports.concurrentOverrides = concurrentOverrides; exports.getSpawnProfile = getSpawnProfile; exports.locateWindowsStandaloneCodex = locateWindowsStandaloneCodex; exports.findDownloaders = findDownloaders; diff --git a/test/spec/CLILocator-test.js b/test/spec/CLILocator-test.js index 9b3aea2525..da77bea7df 100644 --- a/test/spec/CLILocator-test.js +++ b/test/spec/CLILocator-test.js @@ -121,6 +121,40 @@ define(function (require, exports, module) { const result = await nodeConnector.execPeer("locateWindowsStandaloneCodex"); expect(result.located.path).withContext(JSON.stringify(result.located)).toBe(result.installedPath); expect(result.located.source).toBe("native"); + expect(result.pathLookups).toBe(0); + }); + + it("should service a file read and timer while a PATH lookup is still running", async function () { + const result = await nodeConnector.execPeer("lookupWhileBusy"); + expect(result.pendingDuringRead).toBeTrue(); + expect(result.readCompleted).toBeTrue(); + expect(result.downloader).toBe("curl"); + expect(result.timeout).toBe(3000); + expect(result.shell).toBeFalse(); + expect(result.windowsHide).toBeTrue(); + }); + + it("should fall back to plain which when which -a is unsupported within the same deadline", async function () { + const result = await nodeConnector.execPeer("lookupFailure", {failure: "unsupported"}); + expect(result.downloader).toBe("curl"); + expect(result.calls.map(call => call.args)).toEqual([["-a", "curl"], ["curl"]]); + expect(result.calls[1].timeout).toBeGreaterThan(0); + expect(result.calls[1].timeout).toBeLessThanOrEqual(result.calls[0].timeout); + }); + + ["timeout", "missing"].forEach(function (failure) { + it("should not retry a PATH lookup after " + failure, async function () { + const result = await nodeConnector.execPeer("lookupFailure", {failure}); + expect(result.downloader).toBeNull(); + expect(result.calls.map(call => call.args)).toEqual([["-a", "curl"], ["-a", "wget"]]); + }); + }); + + it("should share concurrent checks without mixing different overrides", async function () { + const result = await nodeConnector.execPeer("concurrentOverrides"); + expect(result.shared).toBeTrue(); + expect(result.independent).toBeTrue(); + expect(result.results.map(item => item.path)).toEqual(["/test/one/codex", "/test/two/codex"]); }); it("should use the bundled Claude Code when the system has none, or an older one", async function () { From 01d95e208b934ab72e394104fd9f7bf19911e037 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 16:02:11 +0530 Subject: [PATCH 5/7] fix(ai): localize readiness notice and update Pro pin Add the translated-string key for the wait notification shown when a user clicks or sends before initial AI availability checks finish. Advance the Phoenix Pro pin from f0dcc766 to f33d786, including history navigation fixes, deferred CLI discovery and immediate panel rendering. The pinned revision preserves drafts while checks run and updates setup state in place; it also retains the startup benchmark records and limits. Validation: Pro first-use integration checks 10/10 and Ask AI Send 13/13 on Windows, Mac and Linux. Full CLI suite passes 66/66 on Windows; Mac and Linux retain two existing demo-focus failures each. Pin verified against the local Pro ai branch. No deployment or publication performed. --- src/nls/root/strings.js | 1 + tracking-repos.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index bdeaef1344..5767f81215 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2753,6 +2753,7 @@ define({ "AI_CHAT_SCROLL_TO_BOTTOM": "Scroll to bottom", "AI_CHAT_PLACEHOLDER": "Ask Claude...", "AI_CHAT_SETUP_PLACEHOLDER": "Set up Claude Code to start chatting", + "AI_CHAT_NOT_READY_YET": "AI is getting ready. Please wait a moment.", "AI_CHAT_PLACEHOLDER_MODEL": "Ask {0}...", "AI_CHAT_SEND_TITLE": "Send message", "AI_CHAT_STOP_TITLE": "Stop generation (Esc)", diff --git a/tracking-repos.json b/tracking-repos.json index 95935f0376..027082180e 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "f0dcc76600882aed9a3f9adcf6c58a59b9aab482" + "commitID": "f33d7862473f38323839131e58e97903ba509ec4" } } From 90190a4ed9b93696a61dfd42da0c0ac709cef77e Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 16:03:14 +0530 Subject: [PATCH 6/7] chore(deps): pin Pro with deferred startup follow-ups Advance the Pro pin to ea7586a so the checkout includes the user's latest decision to defer the four remaining startup optimization items. The AI first-render fix and its verification records remain included; this follow-up changes only task documentation and the tracked revision. Validation: tracking-repos.json matches the local Phoenix Pro ai HEAD. --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 027082180e..fade09b454 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "f33d7862473f38323839131e58e97903ba509ec4" + "commitID": "ea7586a88ea44402d9425fd4af539c507360af76" } } From 224af24c21d6466bb519d0d7e7fc3746165c3bb0 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 25 Oct 2026 17:07:39 +0530 Subject: [PATCH 7/7] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index fade09b454..bf04fe490a 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "ea7586a88ea44402d9425fd4af539c507360af76" + "commitID": "c63b994897b344c757263ee4acfe484d64faa61b" } }