From 5d9495b5afc1183a935ed8ed845161ad1b6962c5 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 07:55:25 +0800 Subject: [PATCH 1/3] docs(development): finish version-neutral Python entrypoints Signed-off-by: song --- AGENTS.md | 16 ++++++++++++++++ apps/presentation/dashboard/README.md | 12 +++++++++--- docs/guides/personal-workspace-trial-guide.md | 7 ++++--- scripts/dashboard-dev.sh | 7 ++++--- scripts/install-local.sh | 2 +- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe4d80c686..795e8493b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,22 @@ broader actor lifecycle or authority model than the implementation provides. ## Engineering Quality And Right-Sized Scope +### Source-Checkout Python Entry Points + +Run source development and validation from the intended worktree root with +`uv run --extra test python ...` or `uv run --extra test loopx ...`. Use +`uv sync --extra test` to prepare the project environment. An explicitly +activated compatible environment with the checkout installed remains valid. +Check `sys.executable` and `loopx.__file__` when interpreter or source provenance +is uncertain; a global `loopx` may point to another release snapshot. + +Keep Python subprocesses on the selected interpreter (`sys.executable`), and +keep bootstrap interpreter discovery, supported-version declarations, CI +version coverage, and version-specific fixtures intact. Do not replace those +with a nested `uv run`, rewrite historical execution receipts, or commit a +generated `uv.lock` as part of an unrelated change. See the testing and quality +guide for the validation layers and the source-checkout environment boundary. + ### Refactor Real-Path Validation Before delivering a refactor, validate the affected production entrypoint and diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 03e21a63e0..c53b853531 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -147,13 +147,19 @@ LoopX services are already running separately. Vite proxies the default The full-stack launcher needs a Python 3.11+ interpreter for the status and Chat services. It honors `LOOPX_PYTHON` first, then the Python recorded by the LoopX installer in `.loopx-python`, then the repository `.venv`, -`python3.13`/`python3.12`/`python3.11` on `PATH`, and common Homebrew locations. -If your default `python3` is older, point it at an existing interpreter: +compatible versioned interpreters on `PATH`, and common Homebrew locations. +Prepare the project environment and launch from the repository root: ```bash -LOOPX_PYTHON=/path/to/python3.12 npm run dev +uv sync --extra test +uv run --extra test bash scripts/dashboard-dev.sh ``` +An explicit `LOOPX_PYTHON` or a valid installer-recorded interpreter still takes +precedence. To select the project environment explicitly after `uv sync`, set +`LOOPX_PYTHON` to the absolute path of `.venv/bin/python`. The launcher continues +to support existing compatible Python installations without requiring uv. + Both the root dashboard and the packaged `/chat/` route expose the same installable PWA manifest and icons. The default `loopx dashboard` command opens `/chat/`; its manifest therefore scopes the installed app to `/chat/`. This is diff --git a/docs/guides/personal-workspace-trial-guide.md b/docs/guides/personal-workspace-trial-guide.md index d4a99a0e48..c002cab9a3 100644 --- a/docs/guides/personal-workspace-trial-guide.md +++ b/docs/guides/personal-workspace-trial-guide.md @@ -60,9 +60,10 @@ bash scripts/dashboard-dev.sh ``` ```bash -python3.13 -m loopx.cli chat-endpoint add --config kimi.json # 添加 -python3.13 -m loopx.cli chat-endpoint list # 查看 -python3.13 -m loopx.cli chat-endpoint remove --agent-id kimi # 移除 +# 在仓库根目录运行,使用当前源码的项目环境 +uv run loopx chat-endpoint add --config kimi.json # 添加 +uv run loopx chat-endpoint list # 查看 +uv run loopx chat-endpoint remove --agent-id kimi # 移除 ``` 自定义 Agent 信任范围是 read_only,写入同样走预览确认。 diff --git a/scripts/dashboard-dev.sh b/scripts/dashboard-dev.sh index 1781cef81b..bf55f32479 100755 --- a/scripts/dashboard-dev.sh +++ b/scripts/dashboard-dev.sh @@ -120,9 +120,10 @@ trap cleanup EXIT INT TERM if ! PYTHON_BIN="$(bash "${SCRIPT_DIR}/loopx-python.sh")"; then echo "LoopX requires Python 3.11 or newer to start status and Chat services." >&2 - echo "Install Python 3.11+ (for example: brew install python@3.12), or set" >&2 - echo "LOOPX_PYTHON to an existing Python 3.11+ executable and retry, e.g.:" >&2 - echo " LOOPX_PYTHON=/path/to/python3.12 npm run dev" >&2 + echo "From the repository root, prepare a project environment and retry:" >&2 + echo " uv sync --extra test" >&2 + echo " uv run --extra test bash scripts/dashboard-dev.sh" >&2 + echo "Or set LOOPX_PYTHON to an existing Python 3.11+ executable." >&2 echo "Starting the Vite UI only; use 'npm run dev:web' for the same UI-only preview." >&2 cd "${DASHBOARD_DIR}" exec npm run dev:web diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 9d56dbb553..400a3b8757 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -12,7 +12,7 @@ Options: -h, --help Show this help and exit. Common environment variables: - LOOPX_PYTHON=/path/to/python3.11 Use this supported Python for the release. + LOOPX_PYTHON=/path/to/python Use a Python 3.11+ executable for the release. LOOPX_PROMOTE_DEFAULT=1 Promote this checkout as the default loopx. LOOPX_INSTALL_CANARY=0 Skip the loopx-canary executable. LOOPX_INSTALL_SKILL=0 Skip packaged workflow skills. From 3c576da248828028ed669ca93e85957888493002 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 08:27:21 +0800 Subject: [PATCH 2/3] fix(runtime): discover installed Python minor versions Signed-off-by: song --- .../dsh-loopx-plugin/src/managed-runtime.ts | 32 ++++-- .../admission-closeout.integration.spec.ts | 10 +- .../tests/init-command.spec.ts | 17 +++- .../tests/python-discovery.spec.ts | 32 ++++++ scripts/loopx-python.sh | 68 ++++++++----- tests/test_python_discovery.py | 97 +++++++++++++++++++ 6 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 packages/dsh-loopx-plugin/tests/python-discovery.spec.ts create mode 100644 tests/test_python_discovery.py diff --git a/packages/dsh-loopx-plugin/src/managed-runtime.ts b/packages/dsh-loopx-plugin/src/managed-runtime.ts index fe8fc03e41..7ca84a8da2 100644 --- a/packages/dsh-loopx-plugin/src/managed-runtime.ts +++ b/packages/dsh-loopx-plugin/src/managed-runtime.ts @@ -1,20 +1,13 @@ import { stat } from 'node:fs/promises' +import { readdirSync } from 'node:fs' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { delimiter, join, resolve } from 'node:path' import { resolveLoopXCommand, runFile } from './cli.ts' import type { FileRunner, LoopXCommand } from './cli.ts' export const MANAGED_LAUNCHER_NAME = 'loopx_cli.py' export const MANAGED_SITE_PACKAGES_NAME = 'site-packages' -const PYTHON_CANDIDATES = Object.freeze([ - 'python3', - 'python3.14', - 'python3.13', - 'python3.12', - 'python3.11', -]) - export interface LoopXRuntimeOptions { readonly runner?: FileRunner | undefined readonly signal?: AbortSignal | undefined @@ -35,7 +28,26 @@ export function pluginPythonCandidates( options: LoopXRuntimeOptions, ): readonly string[] { const explicit = configuredPluginPython(options) - return explicit === undefined ? PYTHON_CANDIDATES : [explicit] + if (explicit !== undefined) return [explicit] + const env = options.env ?? process.env + const searchPath = env.PATH ?? env.Path + const discovered = new Map() + for (const directory of searchPath?.split(delimiter) ?? []) { + try { + for (const entry of readdirSync(directory || '.', { withFileTypes: true })) { + const match = /^python3\.(\d+)(?:\.exe)?$/i.exec(entry.name) + if (match?.[1] !== undefined && !entry.isDirectory()) { + discovered.set(entry.name, Number(match[1])) + } + } + } catch { + // Missing or unreadable PATH entries are not interpreter candidates. + } + } + // Preserve python3-first behavior; callers still probe the actual version/pip. + return ['python3', ...[...discovered] + .sort(([nameA, minorA], [nameB, minorB]) => minorB - minorA || nameA.localeCompare(nameB)) + .map(([name]) => name)] } export function pluginAgentsHome(options: LoopXRuntimeOptions): string { diff --git a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts index e7373b4bcc..4b2ac3502e 100644 --- a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts +++ b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { runFile } from '../src/cli.ts' import type { FileRunner } from '../src/cli.ts' import { LoopXContinuationDriver } from '../src/driver.ts' +import { pluginPythonCandidates } from '../src/managed-runtime.ts' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') const sessionId = 'dsh-closeout-session' @@ -19,14 +20,7 @@ const agentId = 'dsh-closeout-agent' const todoId = 'todo_dsh_closeout' const turnInstanceId = 'dsh-closeout-turn-1' const pythonVersionProbe = 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' -const pythonCandidates = [...new Set([ - process.env.PYTHON_BIN, - 'python3', - 'python3.14', - 'python3.13', - 'python3.12', - 'python3.11', -].filter((value): value is string => value !== undefined && value.length > 0))] +const pythonCandidates = pluginPythonCandidates({ env: process.env }) interface Fixture { readonly project: string diff --git a/packages/dsh-loopx-plugin/tests/init-command.spec.ts b/packages/dsh-loopx-plugin/tests/init-command.spec.ts index 8992d03dc3..c073239697 100644 --- a/packages/dsh-loopx-plugin/tests/init-command.spec.ts +++ b/packages/dsh-loopx-plugin/tests/init-command.spec.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { @@ -337,6 +337,11 @@ describe('/loopx-init implementation', () => { }) it('selects an available Python 3.11+ interpreter for install and readback', async () => { + const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-')) + onTestFinished(() => rm(pythonPath, { recursive: true, force: true })) + for (const name of ['python3.14', 'python3.13']) { + await writeFile(join(pythonPath, name), '# fixture executable\n') + } const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = [] let installed = false const runner: FileRunner = async (file, args) => { @@ -374,6 +379,7 @@ describe('/loopx-init implementation', () => { const result = await initializeLoopX({ runner, + env: { PATH: pythonPath }, skillsDir: '/fixture/skills', runtimeDir: '/fixture/runtime', }) @@ -406,6 +412,11 @@ describe('/loopx-init implementation', () => { }) it('skips an implicit version-compatible Python that cannot run pip', async () => { + const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-')) + onTestFinished(() => rm(pythonPath, { recursive: true, force: true })) + for (const name of ['python3.14']) { + await writeFile(join(pythonPath, name), '# fixture executable\n') + } const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = [] let installed = false const runner: FileRunner = async (file, args) => { @@ -450,6 +461,7 @@ describe('/loopx-init implementation', () => { const result = await initializeLoopX({ runner, + env: { PATH: pythonPath }, skillsDir: '/fixture/skills', runtimeDir: '/fixture/runtime', }) @@ -493,7 +505,8 @@ describe('/loopx-init implementation', () => { stage: 'install_cli', causeKind: 'missing', }) - expect(calls.some(call => call.file === 'python3.14')).toBe(false) + expect(calls.filter(call => call.args[0] === '-c') + .every(call => call.file === '/configured/python')).toBe(true) expect(calls.some(call => ( call.file === '/configured/python' && call.args.join(' ') === '-m pip --version' diff --git a/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts b/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts new file mode 100644 index 0000000000..2c8eb10e06 --- /dev/null +++ b/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts @@ -0,0 +1,32 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { pluginPythonCandidates } from '../src/managed-runtime.ts' + +describe('installed Python discovery', () => { + it('discovers unlisted minors in numeric order without executable suffix lookalikes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'loopx-python-discovery-')) + try { + for (const name of ['python3.9', 'python3.97', 'python3.12', 'python3.999-config']) { + await writeFile(join(directory, name), '# fixture executable\n') + } + await mkdir(join(directory, 'python3.998')) + expect(pluginPythonCandidates({ env: { PATH: directory } })).toEqual([ + 'python3', 'python3.97', 'python3.12', 'python3.9', + ]) + expect(pluginPythonCandidates({ env: { PATH: `${directory}${delimiter}${directory}` } })) + .toEqual(['python3', 'python3.97', 'python3.12', 'python3.9']) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('keeps an explicit interpreter authoritative and tolerates missing PATH directories', () => { + expect(pluginPythonCandidates({ pythonBin: '/configured/python', env: { PATH: '/missing' } })) + .toEqual(['/configured/python']) + expect(pluginPythonCandidates({ env: { PATH: '/missing', PYTHON_BIN: '/custom/python' } })) + .toEqual(['/custom/python']) + expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3']) + }) +}) diff --git a/scripts/loopx-python.sh b/scripts/loopx-python.sh index c9688b1120..c000a1a64a 100644 --- a/scripts/loopx-python.sh +++ b/scripts/loopx-python.sh @@ -18,11 +18,35 @@ resolve_candidate() { command -v "${candidate}" 2>/dev/null || true } +# Discover installed minor-version names, then validate the executable itself. +# Numeric ordering avoids selecting python3.9 before python3.14 lexically. +versioned_python_names() { + local directory candidate name minor + for directory in "$@"; do + for candidate in "${directory:-.}"/python3.*; do + [ -x "${candidate}" ] && [ ! -d "${candidate}" ] || continue + name="${candidate##*/}" + minor="${name#python3.}" + case "${minor}" in + ''|*[!0-9]*) continue ;; + esac + printf '%s\n' "${name}" + done + done | sort -t. -k2,2nr | uniq +} + select_loopx_python() { local candidate="" local resolved="" local configured_python="" local authoritative=0 + local directory="" + local remaining_path="${PATH:-}:" + local -a path_directories=() + while [[ "${remaining_path}" == *:* ]]; do + path_directories+=("${remaining_path%%:*}") + remaining_path="${remaining_path#*:}" + done if [ -n "${LOOPX_PYTHON:-}" ]; then configured_python="${LOOPX_PYTHON}" @@ -44,34 +68,34 @@ select_loopx_python() { echo "Ignoring non-functional Python recorded in .loopx-python: ${configured_python}" >&2 fi - for candidate in \ - "${REPO_ROOT}/.venv/bin/python" \ - python3.13 \ - python3.12 \ - python3.11 \ - python3; do - resolved="$(resolve_candidate "${candidate}")" - if [ -n "${resolved}" ] && python_version_ok "${resolved}"; then - printf '%s\n' "${resolved}" - return 0 - fi - done + resolved="$(resolve_candidate "${REPO_ROOT}/.venv/bin/python")" + if [ -n "${resolved}" ] && python_version_ok "${resolved}"; then + printf '%s\n' "${resolved}" + return 0 + fi - for candidate in \ - "${HOME:-}/.local/bin/python3.13" \ - "${HOME:-}/.local/bin/python3.12" \ - "${HOME:-}/.local/bin/python3.11" \ - /opt/homebrew/bin/python3.13 \ - /opt/homebrew/bin/python3.12 \ - /opt/homebrew/bin/python3.11 \ - /usr/local/bin/python3.13 \ - /usr/local/bin/python3.12 \ - /usr/local/bin/python3.11; do + while IFS= read -r candidate; do resolved="$(resolve_candidate "${candidate}")" if [ -n "${resolved}" ] && python_version_ok "${resolved}"; then printf '%s\n' "${resolved}" return 0 fi + done < <(versioned_python_names "${path_directories[@]}") + + resolved="$(resolve_candidate python3)" + if [ -n "${resolved}" ] && python_version_ok "${resolved}"; then + printf '%s\n' "${resolved}" + return 0 + fi + + for directory in "${HOME:-}/.local/bin" /opt/homebrew/bin /usr/local/bin; do + while IFS= read -r candidate; do + resolved="$(resolve_candidate "${directory}/${candidate}")" + if [ -n "${resolved}" ] && python_version_ok "${resolved}"; then + printf '%s\n' "${resolved}" + return 0 + fi + done < <(versioned_python_names "${directory}") done return 1 diff --git a/tests/test_python_discovery.py b/tests/test_python_discovery.py new file mode 100644 index 0000000000..2ba077e1db --- /dev/null +++ b/tests/test_python_discovery.py @@ -0,0 +1,97 @@ +"""Interpreter discovery must not depend on a maintained minor-version list.""" + +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + +import pytest + +pytestmark = pytest.mark.skipif(os.name == "nt", reason="POSIX launcher discovery") + + +def _selector(tmp_path: Path) -> tuple[Path, dict[str, str], Path]: + scripts = tmp_path / "project" / "scripts" + scripts.mkdir(parents=True) + helper = scripts / "loopx-python.sh" + shutil.copyfile(Path(__file__).parents[1] / "scripts/loopx-python.sh", helper) + binaries = tmp_path / "bin with spaces" + binaries.mkdir() + env = {**os.environ, "PATH": f"{binaries}:/usr/bin:/bin", "HOME": str(tmp_path)} + env.pop("LOOPX_PYTHON", None) + return helper, env, binaries + + +def _python(path: Path, *, compatible: bool = True) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + body = f"exec {shlex.quote(sys.executable)} \"$@\"" if compatible else "exit 1" + path.write_text(f"#!/bin/sh\n{body}\n") + path.chmod(0o755) + + +def test_discovers_unlisted_minor_and_runs_the_selected_interpreter(tmp_path: Path): + helper, env, binaries = _selector(tmp_path) + expected = binaries / "python3.97" + _python(expected) + _python(binaries / "python3.98", compatible=False) + _python(binaries / "python3.999-config") + result = subprocess.run(["bash", str(helper)], env=env, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(expected) + executed = subprocess.run( + ["bash", str(helper), "--exec", "-c", "print('selected interpreter ran')"], + env=env, text=True, capture_output=True, + ) + assert executed.returncode == 0, executed.stderr + assert executed.stdout.strip() == "selected interpreter ran" + + +@pytest.mark.parametrize("selection", ["explicit", "recorded", "project_environment"]) +def test_declared_or_project_interpreter_precedes_discovery(tmp_path: Path, selection: str): + helper, env, binaries = _selector(tmp_path) + project = helper.parent.parent + chosen = project / ".venv" / "bin" / "python" + _python(chosen) + _python(binaries / "python3.97") + if selection == "explicit": + env["LOOPX_PYTHON"] = str(chosen) + elif selection == "recorded": + (project / ".loopx-python").write_text(str(chosen) + "\n") + result = subprocess.run(["bash", str(helper)], env=env, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(chosen) + + +def test_invalid_explicit_interpreter_does_not_fall_back(tmp_path: Path): + helper, env, binaries = _selector(tmp_path) + _python(binaries / "python3.97") + env["LOOPX_PYTHON"] = str(binaries / "missing-python") + result = subprocess.run(["bash", str(helper)], env=env, text=True, capture_output=True) + assert result.returncode != 0 + assert not result.stdout.strip() + + +def test_equal_minor_preserves_path_precedence(tmp_path: Path): + helper, env, binaries = _selector(tmp_path) + later = tmp_path / "later-bin" + _python(binaries / "python3.97") + _python(later / "python3.97") + env["PATH"] = f"{binaries}:{later}:/usr/bin:/bin" + result = subprocess.run(["bash", str(helper)], env=env, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(binaries / "python3.97") + + +def test_discovers_unlisted_minor_in_user_install_directory(tmp_path: Path): + helper, env, binaries = _selector(tmp_path) + for name in ("dirname", "sort", "uniq"): + (binaries / name).symlink_to(shutil.which(name)) + _python(binaries / "python3", compatible=False) + expected = tmp_path / ".local" / "bin" / "python3.97" + _python(expected) + env["PATH"] = str(binaries) + result = subprocess.run(["/bin/bash", str(helper)], env=env, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(expected) From 2164327d21e92ec3484a5dd47234b5d1d2ecfe0f Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 08:27:21 +0800 Subject: [PATCH 3/3] docs(runtime): explain Python discovery precedence Signed-off-by: song --- apps/presentation/dashboard/README.md | 4 +++- packages/dsh-loopx-plugin/README.md | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index c53b853531..a565dd5d32 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -147,7 +147,9 @@ LoopX services are already running separately. Vite proxies the default The full-stack launcher needs a Python 3.11+ interpreter for the status and Chat services. It honors `LOOPX_PYTHON` first, then the Python recorded by the LoopX installer in `.loopx-python`, then the repository `.venv`, -compatible versioned interpreters on `PATH`, and common Homebrew locations. +versioned interpreters discovered on `PATH` in descending numeric order, the +unversioned `python3`, and common Homebrew locations. Every discovered executable +must pass the Python compatibility probe; there is no fixed minor-version list. Prepare the project environment and launch from the repository root: ```bash diff --git a/packages/dsh-loopx-plugin/README.md b/packages/dsh-loopx-plugin/README.md index d1e1099522..2f0e6901dd 100644 --- a/packages/dsh-loopx-plugin/README.md +++ b/packages/dsh-loopx-plugin/README.md @@ -39,9 +39,14 @@ deferred atomicity limit are specified in the versioned Requirements are Node.js 22.19+, `pnpm`, Python 3.11+ with `pip`, and network access for the first DSH start when no compatible LoopX CLI is already installed. LoopX itself is deliberately not a prerequisite. The initializer -honors an explicit `PYTHON_BIN`, otherwise it checks `python3`, `python3.14`, `python3.13`, -`python3.12`, and `python3.11` and keeps the first interpreter that satisfies -the requirement. If it must install or upgrade LoopX, it writes an isolated +honors an explicit `PYTHON_BIN`; otherwise it tries `python3`, then discovers +`python3.` executables on the supplied `PATH` in descending numeric order. +Installation still checks the Python version and pip; reopening the managed +runtime uses the same discovery and validates the LoopX CLI. No hard-coded +minor-version list is maintained. When selecting Python for installation, an +invalid explicit interpreter fails instead of falling back. Managed-runtime +readback keeps the existing CLI fallback behavior. If the plugin must install +or upgrade LoopX, it writes an isolated copy under `$DSH_AGENTS_HOME/runtime/dsh-loopx-plugin` (default `~/.agents/runtime/dsh-loopx-plugin`) and never mutates the system Python environment. This works with externally managed Python distributions that