Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions apps/presentation/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,21 @@ 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:
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
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
Expand Down
7 changes: 4 additions & 3 deletions docs/guides/personal-workspace-trial-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,写入同样走预览确认。
Expand Down
11 changes: 8 additions & 3 deletions packages/dsh-loopx-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<minor>` 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
Expand Down
32 changes: 22 additions & 10 deletions packages/dsh-loopx-plugin/src/managed-runtime.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, number>()
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions packages/dsh-loopx-plugin/tests/init-command.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -374,6 +379,7 @@ describe('/loopx-init implementation', () => {

const result = await initializeLoopX({
runner,
env: { PATH: pythonPath },
skillsDir: '/fixture/skills',
runtimeDir: '/fixture/runtime',
})
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -450,6 +461,7 @@ describe('/loopx-init implementation', () => {

const result = await initializeLoopX({
runner,
env: { PATH: pythonPath },
skillsDir: '/fixture/skills',
runtimeDir: '/fixture/runtime',
})
Expand Down Expand Up @@ -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'
Expand Down
32 changes: 32 additions & 0 deletions packages/dsh-loopx-plugin/tests/python-discovery.spec.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
7 changes: 4 additions & 3 deletions scripts/dashboard-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/install-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 46 additions & 22 deletions scripts/loopx-python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down
Loading
Loading