#
tokens: 40936/50000 3/497 files (page 17/20)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 17 of 20. Use http://codebase.md/trycua/cua?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .all-contributorsrc
├── .cursorignore
├── .devcontainer
│   ├── devcontainer.json
│   ├── post-install.sh
│   └── README.md
├── .dockerignore
├── .gitattributes
├── .github
│   ├── FUNDING.yml
│   ├── scripts
│   │   ├── get_pyproject_version.py
│   │   └── tests
│   │       ├── __init__.py
│   │       ├── README.md
│   │       └── test_get_pyproject_version.py
│   └── workflows
│       ├── ci-lume.yml
│       ├── docker-publish-kasm.yml
│       ├── docker-publish-xfce.yml
│       ├── docker-reusable-publish.yml
│       ├── npm-publish-computer.yml
│       ├── npm-publish-core.yml
│       ├── publish-lume.yml
│       ├── pypi-publish-agent.yml
│       ├── pypi-publish-computer-server.yml
│       ├── pypi-publish-computer.yml
│       ├── pypi-publish-core.yml
│       ├── pypi-publish-mcp-server.yml
│       ├── pypi-publish-pylume.yml
│       ├── pypi-publish-som.yml
│       ├── pypi-reusable-publish.yml
│       └── test-validation-script.yml
├── .gitignore
├── .vscode
│   ├── docs.code-workspace
│   ├── launch.json
│   ├── libs-ts.code-workspace
│   ├── lume.code-workspace
│   ├── lumier.code-workspace
│   └── py.code-workspace
├── blog
│   ├── app-use.md
│   ├── assets
│   │   ├── composite-agents.png
│   │   ├── docker-ubuntu-support.png
│   │   ├── hack-booth.png
│   │   ├── hack-closing-ceremony.jpg
│   │   ├── hack-cua-ollama-hud.jpeg
│   │   ├── hack-leaderboard.png
│   │   ├── hack-the-north.png
│   │   ├── hack-winners.jpeg
│   │   ├── hack-workshop.jpeg
│   │   ├── hud-agent-evals.png
│   │   └── trajectory-viewer.jpeg
│   ├── bringing-computer-use-to-the-web.md
│   ├── build-your-own-operator-on-macos-1.md
│   ├── build-your-own-operator-on-macos-2.md
│   ├── composite-agents.md
│   ├── cua-hackathon.md
│   ├── hack-the-north.md
│   ├── hud-agent-evals.md
│   ├── human-in-the-loop.md
│   ├── introducing-cua-cloud-containers.md
│   ├── lume-to-containerization.md
│   ├── sandboxed-python-execution.md
│   ├── training-computer-use-models-trajectories-1.md
│   ├── trajectory-viewer.md
│   ├── ubuntu-docker-support.md
│   └── windows-sandbox.md
├── CONTRIBUTING.md
├── Development.md
├── Dockerfile
├── docs
│   ├── .gitignore
│   ├── .prettierrc
│   ├── content
│   │   └── docs
│   │       ├── agent-sdk
│   │       │   ├── agent-loops.mdx
│   │       │   ├── benchmarks
│   │       │   │   ├── index.mdx
│   │       │   │   ├── interactive.mdx
│   │       │   │   ├── introduction.mdx
│   │       │   │   ├── meta.json
│   │       │   │   ├── osworld-verified.mdx
│   │       │   │   ├── screenspot-pro.mdx
│   │       │   │   └── screenspot-v2.mdx
│   │       │   ├── callbacks
│   │       │   │   ├── agent-lifecycle.mdx
│   │       │   │   ├── cost-saving.mdx
│   │       │   │   ├── index.mdx
│   │       │   │   ├── logging.mdx
│   │       │   │   ├── meta.json
│   │       │   │   ├── pii-anonymization.mdx
│   │       │   │   └── trajectories.mdx
│   │       │   ├── chat-history.mdx
│   │       │   ├── custom-computer-handlers.mdx
│   │       │   ├── custom-tools.mdx
│   │       │   ├── customizing-computeragent.mdx
│   │       │   ├── integrations
│   │       │   │   ├── hud.mdx
│   │       │   │   └── meta.json
│   │       │   ├── message-format.mdx
│   │       │   ├── meta.json
│   │       │   ├── migration-guide.mdx
│   │       │   ├── prompt-caching.mdx
│   │       │   ├── supported-agents
│   │       │   │   ├── composed-agents.mdx
│   │       │   │   ├── computer-use-agents.mdx
│   │       │   │   ├── grounding-models.mdx
│   │       │   │   ├── human-in-the-loop.mdx
│   │       │   │   └── meta.json
│   │       │   ├── supported-model-providers
│   │       │   │   ├── index.mdx
│   │       │   │   └── local-models.mdx
│   │       │   └── usage-tracking.mdx
│   │       ├── computer-sdk
│   │       │   ├── commands.mdx
│   │       │   ├── computer-ui.mdx
│   │       │   ├── computers.mdx
│   │       │   ├── meta.json
│   │       │   └── sandboxed-python.mdx
│   │       ├── index.mdx
│   │       ├── libraries
│   │       │   ├── agent
│   │       │   │   └── index.mdx
│   │       │   ├── computer
│   │       │   │   └── index.mdx
│   │       │   ├── computer-server
│   │       │   │   ├── Commands.mdx
│   │       │   │   ├── index.mdx
│   │       │   │   ├── REST-API.mdx
│   │       │   │   └── WebSocket-API.mdx
│   │       │   ├── core
│   │       │   │   └── index.mdx
│   │       │   ├── lume
│   │       │   │   ├── cli-reference.mdx
│   │       │   │   ├── faq.md
│   │       │   │   ├── http-api.mdx
│   │       │   │   ├── index.mdx
│   │       │   │   ├── installation.mdx
│   │       │   │   ├── meta.json
│   │       │   │   └── prebuilt-images.mdx
│   │       │   ├── lumier
│   │       │   │   ├── building-lumier.mdx
│   │       │   │   ├── docker-compose.mdx
│   │       │   │   ├── docker.mdx
│   │       │   │   ├── index.mdx
│   │       │   │   ├── installation.mdx
│   │       │   │   └── meta.json
│   │       │   ├── mcp-server
│   │       │   │   ├── client-integrations.mdx
│   │       │   │   ├── configuration.mdx
│   │       │   │   ├── index.mdx
│   │       │   │   ├── installation.mdx
│   │       │   │   ├── llm-integrations.mdx
│   │       │   │   ├── meta.json
│   │       │   │   ├── tools.mdx
│   │       │   │   └── usage.mdx
│   │       │   └── som
│   │       │       ├── configuration.mdx
│   │       │       └── index.mdx
│   │       ├── meta.json
│   │       ├── quickstart-cli.mdx
│   │       ├── quickstart-devs.mdx
│   │       └── telemetry.mdx
│   ├── next.config.mjs
│   ├── package-lock.json
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── postcss.config.mjs
│   ├── public
│   │   └── img
│   │       ├── agent_gradio_ui.png
│   │       ├── agent.png
│   │       ├── cli.png
│   │       ├── computer.png
│   │       ├── som_box_threshold.png
│   │       └── som_iou_threshold.png
│   ├── README.md
│   ├── source.config.ts
│   ├── src
│   │   ├── app
│   │   │   ├── (home)
│   │   │   │   ├── [[...slug]]
│   │   │   │   │   └── page.tsx
│   │   │   │   └── layout.tsx
│   │   │   ├── api
│   │   │   │   └── search
│   │   │   │       └── route.ts
│   │   │   ├── favicon.ico
│   │   │   ├── global.css
│   │   │   ├── layout.config.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── llms.mdx
│   │   │   │   └── [[...slug]]
│   │   │   │       └── route.ts
│   │   │   └── llms.txt
│   │   │       └── route.ts
│   │   ├── assets
│   │   │   ├── discord-black.svg
│   │   │   ├── discord-white.svg
│   │   │   ├── logo-black.svg
│   │   │   └── logo-white.svg
│   │   ├── components
│   │   │   ├── iou.tsx
│   │   │   └── mermaid.tsx
│   │   ├── lib
│   │   │   ├── llms.ts
│   │   │   └── source.ts
│   │   └── mdx-components.tsx
│   └── tsconfig.json
├── examples
│   ├── agent_examples.py
│   ├── agent_ui_examples.py
│   ├── computer_examples_windows.py
│   ├── computer_examples.py
│   ├── computer_ui_examples.py
│   ├── computer-example-ts
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .prettierrc
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── pnpm-lock.yaml
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── helpers.ts
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── docker_examples.py
│   ├── evals
│   │   ├── hud_eval_examples.py
│   │   └── wikipedia_most_linked.txt
│   ├── pylume_examples.py
│   ├── sandboxed_functions_examples.py
│   ├── som_examples.py
│   ├── utils.py
│   └── winsandbox_example.py
├── img
│   ├── agent_gradio_ui.png
│   ├── agent.png
│   ├── cli.png
│   ├── computer.png
│   ├── logo_black.png
│   └── logo_white.png
├── libs
│   ├── kasm
│   │   ├── Dockerfile
│   │   ├── LICENSE
│   │   ├── README.md
│   │   └── src
│   │       └── ubuntu
│   │           └── install
│   │               └── firefox
│   │                   ├── custom_startup.sh
│   │                   ├── firefox.desktop
│   │                   └── install_firefox.sh
│   ├── lume
│   │   ├── .cursorignore
│   │   ├── CONTRIBUTING.md
│   │   ├── Development.md
│   │   ├── img
│   │   │   └── cli.png
│   │   ├── Package.resolved
│   │   ├── Package.swift
│   │   ├── README.md
│   │   ├── resources
│   │   │   └── lume.entitlements
│   │   ├── scripts
│   │   │   ├── build
│   │   │   │   ├── build-debug.sh
│   │   │   │   ├── build-release-notarized.sh
│   │   │   │   └── build-release.sh
│   │   │   └── install.sh
│   │   ├── src
│   │   │   ├── Commands
│   │   │   │   ├── Clone.swift
│   │   │   │   ├── Config.swift
│   │   │   │   ├── Create.swift
│   │   │   │   ├── Delete.swift
│   │   │   │   ├── Get.swift
│   │   │   │   ├── Images.swift
│   │   │   │   ├── IPSW.swift
│   │   │   │   ├── List.swift
│   │   │   │   ├── Logs.swift
│   │   │   │   ├── Options
│   │   │   │   │   └── FormatOption.swift
│   │   │   │   ├── Prune.swift
│   │   │   │   ├── Pull.swift
│   │   │   │   ├── Push.swift
│   │   │   │   ├── Run.swift
│   │   │   │   ├── Serve.swift
│   │   │   │   ├── Set.swift
│   │   │   │   └── Stop.swift
│   │   │   ├── ContainerRegistry
│   │   │   │   ├── ImageContainerRegistry.swift
│   │   │   │   ├── ImageList.swift
│   │   │   │   └── ImagesPrinter.swift
│   │   │   ├── Errors
│   │   │   │   └── Errors.swift
│   │   │   ├── FileSystem
│   │   │   │   ├── Home.swift
│   │   │   │   ├── Settings.swift
│   │   │   │   ├── VMConfig.swift
│   │   │   │   ├── VMDirectory.swift
│   │   │   │   └── VMLocation.swift
│   │   │   ├── LumeController.swift
│   │   │   ├── Main.swift
│   │   │   ├── Server
│   │   │   │   ├── Handlers.swift
│   │   │   │   ├── HTTP.swift
│   │   │   │   ├── Requests.swift
│   │   │   │   ├── Responses.swift
│   │   │   │   └── Server.swift
│   │   │   ├── Utils
│   │   │   │   ├── CommandRegistry.swift
│   │   │   │   ├── CommandUtils.swift
│   │   │   │   ├── Logger.swift
│   │   │   │   ├── NetworkUtils.swift
│   │   │   │   ├── Path.swift
│   │   │   │   ├── ProcessRunner.swift
│   │   │   │   ├── ProgressLogger.swift
│   │   │   │   ├── String.swift
│   │   │   │   └── Utils.swift
│   │   │   ├── Virtualization
│   │   │   │   ├── DarwinImageLoader.swift
│   │   │   │   ├── DHCPLeaseParser.swift
│   │   │   │   ├── ImageLoaderFactory.swift
│   │   │   │   └── VMVirtualizationService.swift
│   │   │   ├── VM
│   │   │   │   ├── DarwinVM.swift
│   │   │   │   ├── LinuxVM.swift
│   │   │   │   ├── VM.swift
│   │   │   │   ├── VMDetails.swift
│   │   │   │   ├── VMDetailsPrinter.swift
│   │   │   │   ├── VMDisplayResolution.swift
│   │   │   │   └── VMFactory.swift
│   │   │   └── VNC
│   │   │       ├── PassphraseGenerator.swift
│   │   │       └── VNCService.swift
│   │   └── tests
│   │       ├── Mocks
│   │       │   ├── MockVM.swift
│   │       │   ├── MockVMVirtualizationService.swift
│   │       │   └── MockVNCService.swift
│   │       ├── VM
│   │       │   └── VMDetailsPrinterTests.swift
│   │       ├── VMTests.swift
│   │       ├── VMVirtualizationServiceTests.swift
│   │       └── VNCServiceTests.swift
│   ├── lumier
│   │   ├── .dockerignore
│   │   ├── Dockerfile
│   │   ├── README.md
│   │   └── src
│   │       ├── bin
│   │       │   └── entry.sh
│   │       ├── config
│   │       │   └── constants.sh
│   │       ├── hooks
│   │       │   └── on-logon.sh
│   │       └── lib
│   │           ├── utils.sh
│   │           └── vm.sh
│   ├── python
│   │   ├── agent
│   │   │   ├── agent
│   │   │   │   ├── __init__.py
│   │   │   │   ├── __main__.py
│   │   │   │   ├── adapters
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── huggingfacelocal_adapter.py
│   │   │   │   │   ├── human_adapter.py
│   │   │   │   │   ├── mlxvlm_adapter.py
│   │   │   │   │   └── models
│   │   │   │   │       ├── __init__.py
│   │   │   │   │       ├── generic.py
│   │   │   │   │       ├── internvl.py
│   │   │   │   │       ├── opencua.py
│   │   │   │   │       └── qwen2_5_vl.py
│   │   │   │   ├── agent.py
│   │   │   │   ├── callbacks
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── budget_manager.py
│   │   │   │   │   ├── image_retention.py
│   │   │   │   │   ├── logging.py
│   │   │   │   │   ├── operator_validator.py
│   │   │   │   │   ├── pii_anonymization.py
│   │   │   │   │   ├── prompt_instructions.py
│   │   │   │   │   ├── telemetry.py
│   │   │   │   │   └── trajectory_saver.py
│   │   │   │   ├── cli.py
│   │   │   │   ├── computers
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── cua.py
│   │   │   │   │   └── custom.py
│   │   │   │   ├── decorators.py
│   │   │   │   ├── human_tool
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── __main__.py
│   │   │   │   │   ├── server.py
│   │   │   │   │   └── ui.py
│   │   │   │   ├── integrations
│   │   │   │   │   └── hud
│   │   │   │   │       ├── __init__.py
│   │   │   │   │       ├── agent.py
│   │   │   │   │       └── proxy.py
│   │   │   │   ├── loops
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── anthropic.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── composed_grounded.py
│   │   │   │   │   ├── glm45v.py
│   │   │   │   │   ├── gta1.py
│   │   │   │   │   ├── holo.py
│   │   │   │   │   ├── internvl.py
│   │   │   │   │   ├── model_types.csv
│   │   │   │   │   ├── moondream3.py
│   │   │   │   │   ├── omniparser.py
│   │   │   │   │   ├── openai.py
│   │   │   │   │   ├── opencua.py
│   │   │   │   │   └── uitars.py
│   │   │   │   ├── proxy
│   │   │   │   │   ├── examples.py
│   │   │   │   │   └── handlers.py
│   │   │   │   ├── responses.py
│   │   │   │   ├── types.py
│   │   │   │   └── ui
│   │   │   │       ├── __init__.py
│   │   │   │       ├── __main__.py
│   │   │   │       └── gradio
│   │   │   │           ├── __init__.py
│   │   │   │           ├── app.py
│   │   │   │           └── ui_components.py
│   │   │   ├── benchmarks
│   │   │   │   ├── .gitignore
│   │   │   │   ├── contrib.md
│   │   │   │   ├── interactive.py
│   │   │   │   ├── models
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   └── gta1.py
│   │   │   │   ├── README.md
│   │   │   │   ├── ss-pro.py
│   │   │   │   ├── ss-v2.py
│   │   │   │   └── utils.py
│   │   │   ├── example.py
│   │   │   ├── pyproject.toml
│   │   │   └── README.md
│   │   ├── computer
│   │   │   ├── computer
│   │   │   │   ├── __init__.py
│   │   │   │   ├── computer.py
│   │   │   │   ├── diorama_computer.py
│   │   │   │   ├── helpers.py
│   │   │   │   ├── interface
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── factory.py
│   │   │   │   │   ├── generic.py
│   │   │   │   │   ├── linux.py
│   │   │   │   │   ├── macos.py
│   │   │   │   │   ├── models.py
│   │   │   │   │   └── windows.py
│   │   │   │   ├── logger.py
│   │   │   │   ├── models.py
│   │   │   │   ├── providers
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── cloud
│   │   │   │   │   │   ├── __init__.py
│   │   │   │   │   │   └── provider.py
│   │   │   │   │   ├── docker
│   │   │   │   │   │   ├── __init__.py
│   │   │   │   │   │   └── provider.py
│   │   │   │   │   ├── factory.py
│   │   │   │   │   ├── lume
│   │   │   │   │   │   ├── __init__.py
│   │   │   │   │   │   └── provider.py
│   │   │   │   │   ├── lume_api.py
│   │   │   │   │   ├── lumier
│   │   │   │   │   │   ├── __init__.py
│   │   │   │   │   │   └── provider.py
│   │   │   │   │   └── winsandbox
│   │   │   │   │       ├── __init__.py
│   │   │   │   │       ├── provider.py
│   │   │   │   │       └── setup_script.ps1
│   │   │   │   ├── ui
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── __main__.py
│   │   │   │   │   └── gradio
│   │   │   │   │       ├── __init__.py
│   │   │   │   │       └── app.py
│   │   │   │   └── utils.py
│   │   │   ├── poetry.toml
│   │   │   ├── pyproject.toml
│   │   │   └── README.md
│   │   ├── computer-server
│   │   │   ├── computer_server
│   │   │   │   ├── __init__.py
│   │   │   │   ├── __main__.py
│   │   │   │   ├── cli.py
│   │   │   │   ├── diorama
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── diorama_computer.py
│   │   │   │   │   ├── diorama.py
│   │   │   │   │   ├── draw.py
│   │   │   │   │   ├── macos.py
│   │   │   │   │   └── safezone.py
│   │   │   │   ├── handlers
│   │   │   │   │   ├── base.py
│   │   │   │   │   ├── factory.py
│   │   │   │   │   ├── generic.py
│   │   │   │   │   ├── linux.py
│   │   │   │   │   ├── macos.py
│   │   │   │   │   └── windows.py
│   │   │   │   ├── main.py
│   │   │   │   ├── server.py
│   │   │   │   └── watchdog.py
│   │   │   ├── examples
│   │   │   │   ├── __init__.py
│   │   │   │   └── usage_example.py
│   │   │   ├── pyproject.toml
│   │   │   ├── README.md
│   │   │   ├── run_server.py
│   │   │   └── test_connection.py
│   │   ├── core
│   │   │   ├── core
│   │   │   │   ├── __init__.py
│   │   │   │   └── telemetry
│   │   │   │       ├── __init__.py
│   │   │   │       └── posthog.py
│   │   │   ├── poetry.toml
│   │   │   ├── pyproject.toml
│   │   │   └── README.md
│   │   ├── mcp-server
│   │   │   ├── mcp_server
│   │   │   │   ├── __init__.py
│   │   │   │   ├── __main__.py
│   │   │   │   └── server.py
│   │   │   ├── pyproject.toml
│   │   │   ├── README.md
│   │   │   └── scripts
│   │   │       ├── install_mcp_server.sh
│   │   │       └── start_mcp_server.sh
│   │   ├── pylume
│   │   │   ├── __init__.py
│   │   │   ├── pylume
│   │   │   │   ├── __init__.py
│   │   │   │   ├── client.py
│   │   │   │   ├── exceptions.py
│   │   │   │   ├── lume
│   │   │   │   ├── models.py
│   │   │   │   ├── pylume.py
│   │   │   │   └── server.py
│   │   │   ├── pyproject.toml
│   │   │   └── README.md
│   │   └── som
│   │       ├── LICENSE
│   │       ├── poetry.toml
│   │       ├── pyproject.toml
│   │       ├── README.md
│   │       ├── som
│   │       │   ├── __init__.py
│   │       │   ├── detect.py
│   │       │   ├── detection.py
│   │       │   ├── models.py
│   │       │   ├── ocr.py
│   │       │   ├── util
│   │       │   │   └── utils.py
│   │       │   └── visualization.py
│   │       └── tests
│   │           └── test_omniparser.py
│   ├── typescript
│   │   ├── .gitignore
│   │   ├── .nvmrc
│   │   ├── agent
│   │   │   ├── examples
│   │   │   │   ├── playground-example.html
│   │   │   │   └── README.md
│   │   │   ├── package.json
│   │   │   ├── README.md
│   │   │   ├── src
│   │   │   │   ├── client.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── types.ts
│   │   │   ├── tests
│   │   │   │   └── client.test.ts
│   │   │   ├── tsconfig.json
│   │   │   ├── tsdown.config.ts
│   │   │   └── vitest.config.ts
│   │   ├── biome.json
│   │   ├── computer
│   │   │   ├── .editorconfig
│   │   │   ├── .gitattributes
│   │   │   ├── .gitignore
│   │   │   ├── LICENSE
│   │   │   ├── package.json
│   │   │   ├── README.md
│   │   │   ├── src
│   │   │   │   ├── computer
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── providers
│   │   │   │   │   │   ├── base.ts
│   │   │   │   │   │   ├── cloud.ts
│   │   │   │   │   │   └── index.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── interface
│   │   │   │   │   ├── base.ts
│   │   │   │   │   ├── factory.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── linux.ts
│   │   │   │   │   ├── macos.ts
│   │   │   │   │   └── windows.ts
│   │   │   │   └── types.ts
│   │   │   ├── tests
│   │   │   │   ├── computer
│   │   │   │   │   └── cloud.test.ts
│   │   │   │   ├── interface
│   │   │   │   │   ├── factory.test.ts
│   │   │   │   │   ├── index.test.ts
│   │   │   │   │   ├── linux.test.ts
│   │   │   │   │   ├── macos.test.ts
│   │   │   │   │   └── windows.test.ts
│   │   │   │   └── setup.ts
│   │   │   ├── tsconfig.json
│   │   │   ├── tsdown.config.ts
│   │   │   └── vitest.config.ts
│   │   ├── core
│   │   │   ├── .editorconfig
│   │   │   ├── .gitattributes
│   │   │   ├── .gitignore
│   │   │   ├── LICENSE
│   │   │   ├── package.json
│   │   │   ├── README.md
│   │   │   ├── src
│   │   │   │   ├── index.ts
│   │   │   │   └── telemetry
│   │   │   │       ├── clients
│   │   │   │       │   ├── index.ts
│   │   │   │       │   └── posthog.ts
│   │   │   │       └── index.ts
│   │   │   ├── tests
│   │   │   │   └── telemetry.test.ts
│   │   │   ├── tsconfig.json
│   │   │   ├── tsdown.config.ts
│   │   │   └── vitest.config.ts
│   │   ├── package.json
│   │   ├── pnpm-lock.yaml
│   │   ├── pnpm-workspace.yaml
│   │   └── README.md
│   └── xfce
│       ├── .dockerignore
│       ├── .gitignore
│       ├── Dockerfile
│       ├── README.md
│       └── src
│           ├── scripts
│           │   ├── resize-display.sh
│           │   ├── start-computer-server.sh
│           │   ├── start-novnc.sh
│           │   ├── start-vnc.sh
│           │   └── xstartup.sh
│           ├── supervisor
│           │   └── supervisord.conf
│           └── xfce-config
│               ├── helpers.rc
│               ├── xfce4-power-manager.xml
│               └── xfce4-session.xml
├── LICENSE.md
├── notebooks
│   ├── agent_nb.ipynb
│   ├── blog
│   │   ├── build-your-own-operator-on-macos-1.ipynb
│   │   └── build-your-own-operator-on-macos-2.ipynb
│   ├── composite_agents_docker_nb.ipynb
│   ├── computer_nb.ipynb
│   ├── computer_server_nb.ipynb
│   ├── customizing_computeragent.ipynb
│   ├── eval_osworld.ipynb
│   ├── ollama_nb.ipynb
│   ├── pylume_nb.ipynb
│   ├── README.md
│   ├── sota_hackathon_cloud.ipynb
│   └── sota_hackathon.ipynb
├── pdm.lock
├── pyproject.toml
├── pyrightconfig.json
├── README.md
├── samples
│   └── community
│       ├── global-online
│       │   └── README.md
│       └── hack-the-north
│           └── README.md
├── scripts
│   ├── build-uv.sh
│   ├── build.ps1
│   ├── build.sh
│   ├── cleanup.sh
│   ├── playground-docker.sh
│   ├── playground.sh
│   └── run-docker-dev.sh
└── tests
    ├── pytest.ini
    ├── shell_cmd.py
    ├── test_files.py
    ├── test_shell_bash.py
    ├── test_telemetry.py
    ├── test_venv.py
    └── test_watchdog.py
```

# Files

--------------------------------------------------------------------------------
/libs/lume/src/LumeController.swift:
--------------------------------------------------------------------------------

```swift
   1 | import ArgumentParser
   2 | import Foundation
   3 | import Virtualization
   4 | 
   5 | // MARK: - Shared VM Manager
   6 | 
   7 | @MainActor
   8 | final class SharedVM {
   9 |     static let shared: SharedVM = SharedVM()
  10 |     private var runningVMs: [String: VM] = [:]
  11 | 
  12 |     private init() {}
  13 | 
  14 |     func getVM(name: String) -> VM? {
  15 |         return runningVMs[name]
  16 |     }
  17 | 
  18 |     func setVM(name: String, vm: VM) {
  19 |         runningVMs[name] = vm
  20 |     }
  21 | 
  22 |     func removeVM(name: String) {
  23 |         runningVMs.removeValue(forKey: name)
  24 |     }
  25 | }
  26 | 
  27 | /// Entrypoint for Commands and API server
  28 | final class LumeController {
  29 |     // MARK: - Properties
  30 | 
  31 |     let home: Home
  32 |     private let imageLoaderFactory: ImageLoaderFactory
  33 |     private let vmFactory: VMFactory
  34 | 
  35 |     // MARK: - Initialization
  36 | 
  37 |     init(
  38 |         home: Home = Home(),
  39 |         imageLoaderFactory: ImageLoaderFactory = DefaultImageLoaderFactory(),
  40 |         vmFactory: VMFactory = DefaultVMFactory()
  41 |     ) {
  42 |         self.home = home
  43 |         self.imageLoaderFactory = imageLoaderFactory
  44 |         self.vmFactory = vmFactory
  45 |     }
  46 | 
  47 |     // MARK: - Public VM Management Methods
  48 | 
  49 |     /// Lists all virtual machines in the system
  50 |     @MainActor
  51 |     public func list(storage: String? = nil) throws -> [VMDetails] {
  52 |         do {
  53 |             if let storage = storage {
  54 |                 // If storage is specified, only return VMs from that location
  55 |                 if storage.contains("/") || storage.contains("\\") {
  56 |                     // Direct path - check if it exists
  57 |                     if !FileManager.default.fileExists(atPath: storage) {
  58 |                         // Return empty array if the path doesn't exist
  59 |                         return []
  60 |                     }
  61 |                     
  62 |                     // Try to get all VMs from the specified path
  63 |                     // We need to check which subdirectories are valid VM dirs
  64 |                     let directoryURL = URL(fileURLWithPath: storage)
  65 |                     let contents = try FileManager.default.contentsOfDirectory(
  66 |                         at: directoryURL,
  67 |                         includingPropertiesForKeys: [.isDirectoryKey],
  68 |                         options: .skipsHiddenFiles
  69 |                     )
  70 |                     
  71 |                     let statuses = try contents.compactMap { subdir -> VMDetails? in
  72 |                         guard let isDirectory = try subdir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory,
  73 |                               isDirectory else {
  74 |                             return nil
  75 |                         }
  76 |                         
  77 |                         let vmName = subdir.lastPathComponent
  78 |                         // Check if it's a valid VM directory
  79 |                         let vmDir = try home.getVMDirectoryFromPath(vmName, storagePath: storage)
  80 |                         if !vmDir.initialized() {
  81 |                             return nil
  82 |                         }
  83 |                         
  84 |                         do {
  85 |                             let vm = try self.get(name: vmName, storage: storage)
  86 |                             return vm.details
  87 |                         } catch {
  88 |                             // Skip invalid VM directories
  89 |                             return nil
  90 |                         }
  91 |                     }
  92 |                     return statuses
  93 |                 } else {
  94 |                     // Named storage
  95 |                     let vmsWithLoc = try home.getAllVMDirectories()
  96 |                     let statuses = try vmsWithLoc.compactMap { vmWithLoc -> VMDetails? in
  97 |                         // Only include VMs from the specified location
  98 |                         if vmWithLoc.locationName != storage {
  99 |                             return nil
 100 |                         }
 101 |                         let vm = try self.get(
 102 |                             name: vmWithLoc.directory.name, storage: vmWithLoc.locationName)
 103 |                         return vm.details
 104 |                     }
 105 |                     return statuses
 106 |                 }
 107 |             } else {
 108 |                 // No storage filter - get all VMs
 109 |                 let vmsWithLoc = try home.getAllVMDirectories()
 110 |                 let statuses = try vmsWithLoc.compactMap { vmWithLoc -> VMDetails? in
 111 |                     let vm = try self.get(
 112 |                         name: vmWithLoc.directory.name, storage: vmWithLoc.locationName)
 113 |                     return vm.details
 114 |                 }
 115 |                 return statuses
 116 |             }
 117 |         } catch {
 118 |             Logger.error("Failed to list VMs", metadata: ["error": error.localizedDescription])
 119 |             throw error
 120 |         }
 121 |     }
 122 | 
 123 |     @MainActor
 124 |     public func clone(
 125 |         name: String, newName: String, sourceLocation: String? = nil, destLocation: String? = nil
 126 |     ) throws {
 127 |         let normalizedName = normalizeVMName(name: name)
 128 |         let normalizedNewName = normalizeVMName(name: newName)
 129 |         Logger.info(
 130 |             "Cloning VM",
 131 |             metadata: [
 132 |                 "source": normalizedName,
 133 |                 "destination": normalizedNewName,
 134 |                 "sourceLocation": sourceLocation ?? "default",
 135 |                 "destLocation": destLocation ?? "default",
 136 |             ])
 137 | 
 138 |         do {
 139 |             // Validate source VM exists
 140 |             _ = try self.validateVMExists(normalizedName, storage: sourceLocation)
 141 | 
 142 |             // Get the source VM and check if it's running
 143 |             let sourceVM = try get(name: normalizedName, storage: sourceLocation)
 144 |             if sourceVM.details.status == "running" {
 145 |                 Logger.error("Cannot clone a running VM", metadata: ["source": normalizedName])
 146 |                 throw VMError.alreadyRunning(normalizedName)
 147 |             }
 148 | 
 149 |             // Check if destination already exists
 150 |             do {
 151 |                 let destDir = try home.getVMDirectory(normalizedNewName, storage: destLocation)
 152 |                 if destDir.exists() {
 153 |                     Logger.error(
 154 |                         "Destination VM already exists",
 155 |                         metadata: ["destination": normalizedNewName])
 156 |                     throw HomeError.directoryAlreadyExists(path: destDir.dir.path)
 157 |                 }
 158 |             } catch VMLocationError.locationNotFound {
 159 |                 // Location not found is okay, we'll create it
 160 |             } catch VMError.notFound {
 161 |                 // VM not found is okay, we'll create it
 162 |             }
 163 | 
 164 |             // Copy the VM directory
 165 |             try home.copyVMDirectory(
 166 |                 from: normalizedName,
 167 |                 to: normalizedNewName,
 168 |                 sourceLocation: sourceLocation,
 169 |                 destLocation: destLocation
 170 |             )
 171 | 
 172 |             // Update MAC address in the cloned VM to ensure uniqueness
 173 |             let clonedVM = try get(name: normalizedNewName, storage: destLocation)
 174 |             try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
 175 | 
 176 |             // Update MAC Identifier in the cloned VM to ensure uniqueness
 177 |             try clonedVM.setMachineIdentifier(
 178 |                 DarwinVirtualizationService.generateMachineIdentifier())
 179 | 
 180 |             Logger.info(
 181 |                 "VM cloned successfully",
 182 |                 metadata: ["source": normalizedName, "destination": normalizedNewName])
 183 |         } catch {
 184 |             Logger.error("Failed to clone VM", metadata: ["error": error.localizedDescription])
 185 |             throw error
 186 |         }
 187 |     }
 188 | 
 189 |     @MainActor
 190 |     public func get(name: String, storage: String? = nil) throws -> VM {
 191 |         let normalizedName = normalizeVMName(name: name)
 192 |         do {
 193 |             let vm: VM
 194 |             if let storagePath = storage, storagePath.contains("/") || storagePath.contains("\\") {
 195 |                 // Storage is a direct path
 196 |                 let vmDir = try home.getVMDirectoryFromPath(normalizedName, storagePath: storagePath)
 197 |                 guard vmDir.initialized() else {
 198 |                     // Throw a specific error if the directory exists but isn't a valid VM
 199 |                     if vmDir.exists() {
 200 |                         throw VMError.notInitialized(normalizedName)
 201 |                     } else {
 202 |                         throw VMError.notFound(normalizedName)
 203 |                     }
 204 |                 }
 205 |                 // Pass the path as the storage context
 206 |                 vm = try self.loadVM(vmDir: vmDir, storage: storagePath)
 207 |             } else {
 208 |                 // Storage is nil or a named location
 209 |                 let actualLocation = try self.validateVMExists(
 210 |                     normalizedName, storage: storage)
 211 | 
 212 |                 let vmDir = try home.getVMDirectory(normalizedName, storage: actualLocation)
 213 |                 // loadVM will re-check initialized, but good practice to keep validateVMExists result.
 214 |                 vm = try self.loadVM(vmDir: vmDir, storage: actualLocation)
 215 |             }
 216 |             return vm
 217 |         } catch {
 218 |             Logger.error(
 219 |                 "Failed to get VM",
 220 |                 metadata: [
 221 |                     "vmName": normalizedName, "storage": storage ?? "default",
 222 |                     "error": error.localizedDescription,
 223 |                 ])
 224 |             // Re-throw the original error to preserve its type
 225 |             throw error
 226 |         }
 227 |     }
 228 | 
 229 |     @MainActor
 230 |     public func create(
 231 |         name: String,
 232 |         os: String,
 233 |         diskSize: UInt64,
 234 |         cpuCount: Int,
 235 |         memorySize: UInt64,
 236 |         display: String,
 237 |         ipsw: String?,
 238 |         storage: String? = nil
 239 |     ) async throws {
 240 |         Logger.info(
 241 |             "Creating VM",
 242 |             metadata: [
 243 |                 "name": name,
 244 |                 "os": os,
 245 |                 "location": storage ?? "default",
 246 |                 "disk_size": "\(diskSize / 1024 / 1024)MB",
 247 |                 "cpu_count": "\(cpuCount)",
 248 |                 "memory_size": "\(memorySize / 1024 / 1024)MB",
 249 |                 "display": display,
 250 |                 "ipsw": ipsw ?? "none",
 251 |             ])
 252 | 
 253 |         do {
 254 |             try validateCreateParameters(name: name, os: os, ipsw: ipsw, storage: storage)
 255 | 
 256 |             let vm = try await createTempVMConfig(
 257 |                 os: os,
 258 |                 cpuCount: cpuCount,
 259 |                 memorySize: memorySize,
 260 |                 diskSize: diskSize,
 261 |                 display: display
 262 |             )
 263 | 
 264 |             try await vm.setup(
 265 |                 ipswPath: ipsw ?? "none",
 266 |                 cpuCount: cpuCount,
 267 |                 memorySize: memorySize,
 268 |                 diskSize: diskSize,
 269 |                 display: display
 270 |             )
 271 | 
 272 |             try vm.finalize(to: name, home: home, storage: storage)
 273 | 
 274 |             Logger.info("VM created successfully", metadata: ["name": name])
 275 |         } catch {
 276 |             Logger.error("Failed to create VM", metadata: ["error": error.localizedDescription])
 277 |             throw error
 278 |         }
 279 |     }
 280 | 
 281 |     @MainActor
 282 |     public func delete(name: String, storage: String? = nil) async throws {
 283 |         let normalizedName = normalizeVMName(name: name)
 284 |         Logger.info(
 285 |             "Deleting VM",
 286 |             metadata: [
 287 |                 "name": normalizedName,
 288 |                 "location": storage ?? "default",
 289 |             ])
 290 | 
 291 |         do {
 292 |             let vmDir: VMDirectory
 293 |             
 294 |             // Check if storage is a direct path
 295 |             if let storagePath = storage, storagePath.contains("/") || storagePath.contains("\\") {
 296 |                 // Storage is a direct path
 297 |                 vmDir = try home.getVMDirectoryFromPath(normalizedName, storagePath: storagePath)
 298 |                 guard vmDir.initialized() else {
 299 |                     // Throw a specific error if the directory exists but isn't a valid VM
 300 |                     if vmDir.exists() {
 301 |                         throw VMError.notInitialized(normalizedName)
 302 |                     } else {
 303 |                         throw VMError.notFound(normalizedName)
 304 |                     }
 305 |                 }
 306 |             } else {
 307 |                 // Storage is nil or a named location
 308 |                 let actualLocation = try self.validateVMExists(normalizedName, storage: storage)
 309 |                 vmDir = try home.getVMDirectory(normalizedName, storage: actualLocation)
 310 |             }
 311 |             
 312 |             // Stop VM if it's running
 313 |             if SharedVM.shared.getVM(name: normalizedName) != nil {
 314 |                 try await stopVM(name: normalizedName)
 315 |             }
 316 |             
 317 |             try vmDir.delete()
 318 |             
 319 |             Logger.info("VM deleted successfully", metadata: ["name": normalizedName])
 320 |             
 321 |         } catch {
 322 |             Logger.error("Failed to delete VM", metadata: ["error": error.localizedDescription])
 323 |             throw error
 324 |         }
 325 |     }
 326 | 
 327 |     // MARK: - VM Operations
 328 | 
 329 |     @MainActor
 330 |     public func updateSettings(
 331 |         name: String,
 332 |         cpu: Int? = nil,
 333 |         memory: UInt64? = nil,
 334 |         diskSize: UInt64? = nil,
 335 |         display: String? = nil,
 336 |         storage: String? = nil
 337 |     ) throws {
 338 |         let normalizedName = normalizeVMName(name: name)
 339 |         Logger.info(
 340 |             "Updating VM settings",
 341 |             metadata: [
 342 |                 "name": normalizedName,
 343 |                 "location": storage ?? "default",
 344 |                 "cpu": cpu.map { "\($0)" } ?? "unchanged",
 345 |                 "memory": memory.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
 346 |                 "disk_size": diskSize.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
 347 |                 "display": display ?? "unchanged",
 348 |             ])
 349 |         do {
 350 |             // Find the actual location of the VM
 351 |             let actualLocation = try self.validateVMExists(
 352 |                 normalizedName, storage: storage)
 353 | 
 354 |             let vm = try get(name: normalizedName, storage: actualLocation)
 355 | 
 356 |             // Apply settings in order
 357 |             if let cpu = cpu {
 358 |                 try vm.setCpuCount(cpu)
 359 |             }
 360 |             if let memory = memory {
 361 |                 try vm.setMemorySize(memory)
 362 |             }
 363 |             if let diskSize = diskSize {
 364 |                 try vm.setDiskSize(diskSize)
 365 |             }
 366 |             if let display = display {
 367 |                 try vm.setDisplay(display)
 368 |             }
 369 | 
 370 |             Logger.info("VM settings updated successfully", metadata: ["name": normalizedName])
 371 |         } catch {
 372 |             Logger.error(
 373 |                 "Failed to update VM settings", metadata: ["error": error.localizedDescription])
 374 |             throw error
 375 |         }
 376 |     }
 377 | 
 378 |     @MainActor
 379 |     public func stopVM(name: String, storage: String? = nil) async throws {
 380 |         let normalizedName = normalizeVMName(name: name)
 381 |         Logger.info("Stopping VM", metadata: ["name": normalizedName])
 382 | 
 383 |         do {
 384 |             // Find the actual location of the VM
 385 |             let actualLocation = try self.validateVMExists(
 386 |                 normalizedName, storage: storage)
 387 | 
 388 |             // Try to get VM from cache first
 389 |             let vm: VM
 390 |             if let cachedVM = SharedVM.shared.getVM(name: normalizedName) {
 391 |                 vm = cachedVM
 392 |             } else {
 393 |                 vm = try get(name: normalizedName, storage: actualLocation)
 394 |             }
 395 | 
 396 |             try await vm.stop()
 397 |             // Remove VM from cache after stopping
 398 |             SharedVM.shared.removeVM(name: normalizedName)
 399 |             Logger.info("VM stopped successfully", metadata: ["name": normalizedName])
 400 |         } catch {
 401 |             // Clean up cache even if stop fails
 402 |             SharedVM.shared.removeVM(name: normalizedName)
 403 |             Logger.error("Failed to stop VM", metadata: ["error": error.localizedDescription])
 404 |             throw error
 405 |         }
 406 |     }
 407 | 
 408 |     @MainActor
 409 |     public func runVM(
 410 |         name: String,
 411 |         noDisplay: Bool = false,
 412 |         sharedDirectories: [SharedDirectory] = [],
 413 |         mount: Path? = nil,
 414 |         registry: String = "ghcr.io",
 415 |         organization: String = "trycua",
 416 |         vncPort: Int = 0,
 417 |         recoveryMode: Bool = false,
 418 |         storage: String? = nil,
 419 |         usbMassStoragePaths: [Path]? = nil
 420 |     ) async throws {
 421 |         let normalizedName = normalizeVMName(name: name)
 422 |         Logger.info(
 423 |             "Running VM",
 424 |             metadata: [
 425 |                 "name": normalizedName,
 426 |                 "no_display": "\(noDisplay)",
 427 |                 "shared_directories":
 428 |                     "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
 429 |                 "mount": mount?.path ?? "none",
 430 |                 "vnc_port": "\(vncPort)",
 431 |                 "recovery_mode": "\(recoveryMode)",
 432 |                 "storage_param": storage ?? "default", // Log the original param
 433 |                 "usb_storage_devices": "\(usbMassStoragePaths?.count ?? 0)",
 434 |             ])
 435 | 
 436 |         do {
 437 |             // Check if name is an image ref to auto-pull
 438 |             let components = normalizedName.split(separator: ":")
 439 |             if components.count == 2 { // Check if it looks like image:tag
 440 |                 // Attempt to validate if VM exists first, suppressing the error
 441 |                 // This avoids pulling if the VM already exists, even if name looks like an image ref
 442 |                 let vmExists = (try? self.validateVMExists(normalizedName, storage: storage)) != nil
 443 |                 if !vmExists {
 444 |                     Logger.info(
 445 |                         "VM not found, attempting to pull image based on name",
 446 |                         metadata: ["imageRef": normalizedName])
 447 |                     // Use the potentially new VM name derived from the image ref
 448 |                     let potentialVMName = String(components[0])
 449 |                     try await pullImage(
 450 |                         image: normalizedName, // Full image ref
 451 |                         name: potentialVMName, // Name derived from image
 452 |                         registry: registry,
 453 |                         organization: organization,
 454 |                         storage: storage
 455 |                     )
 456 |                     // Important: After pull, the effective name might have changed
 457 |                     // We proceed assuming the user wants to run the VM derived from image name
 458 |                     // normalizedName = potentialVMName // Re-assign normalizedName if pull logic creates it
 459 |                     // Note: Current pullImage doesn't return the final VM name, 
 460 |                     // so we assume it matches the name derived from the image.
 461 |                     // This might need refinement if pullImage behaviour changes.
 462 |                 }
 463 |             }
 464 | 
 465 |             // Determine effective storage path or name AND get the VMDirectory
 466 |             let effectiveStorage: String?
 467 |             let vmDir: VMDirectory
 468 | 
 469 |             if let storagePath = storage, storagePath.contains("/") || storagePath.contains("\\") {
 470 |                 // Storage is a direct path
 471 |                 vmDir = try home.getVMDirectoryFromPath(normalizedName, storagePath: storagePath)
 472 |                 guard vmDir.initialized() else {
 473 |                     if vmDir.exists() {
 474 |                         throw VMError.notInitialized(normalizedName)
 475 |                     } else {
 476 |                         throw VMError.notFound(normalizedName)
 477 |                     }
 478 |                 }
 479 |                 effectiveStorage = storagePath // Use the path string
 480 |                 Logger.info("Using direct storage path", metadata: ["path": storagePath])
 481 |             } else {
 482 |                 // Storage is nil or a named location - validate and get the actual name
 483 |                 let actualLocationName = try validateVMExists(normalizedName, storage: storage)
 484 |                 vmDir = try home.getVMDirectory(normalizedName, storage: actualLocationName) // Get VMDir for named location
 485 |                 effectiveStorage = actualLocationName // Use the named location string
 486 |                 Logger.info(
 487 |                     "Using named storage location",
 488 |                     metadata: [
 489 |                         "requested": storage ?? "default",
 490 |                         "actual": actualLocationName ?? "default",
 491 |                     ])
 492 |             }
 493 | 
 494 |             // Validate parameters using the located VMDirectory
 495 |             try validateRunParameters(
 496 |                 vmDir: vmDir, // Pass vmDir
 497 |                 sharedDirectories: sharedDirectories,
 498 |                 mount: mount,
 499 |                 usbMassStoragePaths: usbMassStoragePaths
 500 |             )
 501 | 
 502 |             // Load the VM directly using the located VMDirectory and storage context
 503 |             let vm = try self.loadVM(vmDir: vmDir, storage: effectiveStorage)
 504 | 
 505 |             SharedVM.shared.setVM(name: normalizedName, vm: vm)
 506 |             try await vm.run(
 507 |                 noDisplay: noDisplay,
 508 |                 sharedDirectories: sharedDirectories,
 509 |                 mount: mount,
 510 |                 vncPort: vncPort,
 511 |                 recoveryMode: recoveryMode,
 512 |                 usbMassStoragePaths: usbMassStoragePaths)
 513 |             Logger.info("VM started successfully", metadata: ["name": normalizedName])
 514 |         } catch {
 515 |             SharedVM.shared.removeVM(name: normalizedName)
 516 |             Logger.error("Failed to run VM", metadata: ["error": error.localizedDescription])
 517 |             throw error
 518 |         }
 519 |     }
 520 | 
 521 |     // MARK: - Image Management
 522 | 
 523 |     @MainActor
 524 |     public func getLatestIPSWURL() async throws -> URL {
 525 |         Logger.info("Fetching latest supported IPSW URL")
 526 | 
 527 |         do {
 528 |             let imageLoader = DarwinImageLoader()
 529 |             let url = try await imageLoader.fetchLatestSupportedURL()
 530 |             Logger.info("Found latest IPSW URL", metadata: ["url": url.absoluteString])
 531 |             return url
 532 |         } catch {
 533 |             Logger.error(
 534 |                 "Failed to fetch IPSW URL", metadata: ["error": error.localizedDescription])
 535 |             throw error
 536 |         }
 537 |     }
 538 | 
 539 |     @MainActor
 540 |     public func pullImage(
 541 |         image: String,
 542 |         name: String?,
 543 |         registry: String,
 544 |         organization: String,
 545 |         storage: String? = nil
 546 |     ) async throws {
 547 |         do {
 548 |             // Convert non-sparse image to sparse version if needed
 549 |             var actualImage = image
 550 |             var actualName = name
 551 | 
 552 |             // Split the image to get name and tag for both sparse and non-sparse cases
 553 |             let components = image.split(separator: ":")
 554 |             guard components.count == 2 else {
 555 |                 throw ValidationError("Invalid image format. Expected format: name:tag")
 556 |             }
 557 | 
 558 |             let originalName = String(components[0])
 559 |             let tag = String(components[1])
 560 | 
 561 |             // For consistent VM naming, strip "-sparse" suffix if present when no name provided
 562 |             let normalizedBaseName: String
 563 |             if originalName.hasSuffix("-sparse") {
 564 |                 normalizedBaseName = String(originalName.dropLast(7))  // drop "-sparse"
 565 |             } else {
 566 |                 normalizedBaseName = originalName
 567 |             }
 568 | 
 569 |             // Set default VM name if not provided
 570 |             if actualName == nil {
 571 |                 actualName = "\(normalizedBaseName)_\(tag)"
 572 |             }
 573 | 
 574 |             // Convert non-sparse image to sparse version if needed
 575 |             if !image.contains("-sparse") {
 576 |                 // Create sparse version of the image name
 577 |                 actualImage = "\(originalName)-sparse:\(tag)"
 578 | 
 579 |                 Logger.info(
 580 |                     "Converting to sparse image",
 581 |                     metadata: [
 582 |                         "original": image,
 583 |                         "sparse": actualImage,
 584 |                         "vm_name": actualName ?? "default",
 585 |                     ]
 586 |                 )
 587 |             }
 588 | 
 589 |             let vmName = actualName ?? "default"  // Just use actualName as it's already normalized
 590 | 
 591 |             Logger.info(
 592 |                 "Pulling image",
 593 |                 metadata: [
 594 |                     "image": actualImage,
 595 |                     "name": vmName,
 596 |                     "registry": registry,
 597 |                     "organization": organization,
 598 |                     "location": storage ?? "default",
 599 |                 ])
 600 | 
 601 |             try self.validatePullParameters(
 602 |                 image: actualImage,
 603 |                 name: vmName,
 604 |                 registry: registry,
 605 |                 organization: organization,
 606 |                 storage: storage
 607 |             )
 608 | 
 609 |             let imageContainerRegistry = ImageContainerRegistry(
 610 |                 registry: registry, organization: organization)
 611 |             let _ = try await imageContainerRegistry.pull(
 612 |                 image: actualImage,
 613 |                 name: vmName,
 614 |                 locationName: storage)
 615 | 
 616 |             Logger.info(
 617 |                 "Setting new VM mac address",
 618 |                 metadata: [
 619 |                     "vm_name": vmName,
 620 |                     "location": storage ?? "default",
 621 |                 ])
 622 | 
 623 |             // Update MAC address in the cloned VM to ensure uniqueness
 624 |             let vm = try get(name: vmName, storage: storage)
 625 |             try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
 626 | 
 627 |             Logger.info(
 628 |                 "Image pulled successfully",
 629 |                 metadata: [
 630 |                     "image": actualImage,
 631 |                     "name": vmName,
 632 |                     "registry": registry,
 633 |                     "organization": organization,
 634 |                     "location": storage ?? "default",
 635 |                 ])
 636 |         } catch {
 637 |             Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription])
 638 |             throw error
 639 |         }
 640 |     }
 641 | 
 642 |     @MainActor
 643 |     public func pushImage(
 644 |         name: String,
 645 |         imageName: String,
 646 |         tags: [String],
 647 |         registry: String,
 648 |         organization: String,
 649 |         storage: String? = nil,
 650 |         chunkSizeMb: Int = 512,
 651 |         verbose: Bool = false,
 652 |         dryRun: Bool = false,
 653 |         reassemble: Bool = false
 654 |     ) async throws {
 655 |         do {
 656 |             Logger.info(
 657 |                 "Pushing VM to registry",
 658 |                 metadata: [
 659 |                     "name": name,
 660 |                     "imageName": imageName,
 661 |                     "tags": "\(tags.joined(separator: ", "))",
 662 |                     "registry": registry,
 663 |                     "organization": organization,
 664 |                     "location": storage ?? "default",
 665 |                     "chunk_size": "\(chunkSizeMb)MB",
 666 |                     "dry_run": "\(dryRun)",
 667 |                     "reassemble": "\(reassemble)",
 668 |                 ])
 669 | 
 670 |             try validatePushParameters(
 671 |                 name: name,
 672 |                 imageName: imageName,
 673 |                 tags: tags,
 674 |                 registry: registry,
 675 |                 organization: organization
 676 |             )
 677 | 
 678 |             // Find the actual location of the VM
 679 |             let actualLocation = try self.validateVMExists(name, storage: storage)
 680 | 
 681 |             // Get the VM directory
 682 |             let vmDir = try home.getVMDirectory(name, storage: actualLocation)
 683 | 
 684 |             // Use ImageContainerRegistry to push the VM
 685 |             let imageContainerRegistry = ImageContainerRegistry(
 686 |                 registry: registry, organization: organization)
 687 | 
 688 |             try await imageContainerRegistry.push(
 689 |                 vmDirPath: vmDir.dir.path,
 690 |                 imageName: imageName,
 691 |                 tags: tags,
 692 |                 chunkSizeMb: chunkSizeMb,
 693 |                 verbose: verbose,
 694 |                 dryRun: dryRun,
 695 |                 reassemble: reassemble
 696 |             )
 697 | 
 698 |             Logger.info(
 699 |                 "VM pushed successfully",
 700 |                 metadata: [
 701 |                     "name": name,
 702 |                     "imageName": imageName,
 703 |                     "tags": "\(tags.joined(separator: ", "))",
 704 |                     "registry": registry,
 705 |                     "organization": organization,
 706 |                 ])
 707 |         } catch {
 708 |             Logger.error("Failed to push VM", metadata: ["error": error.localizedDescription])
 709 |             throw error
 710 |         }
 711 |     }
 712 | 
 713 |     @MainActor
 714 |     public func pruneImages() async throws {
 715 |         Logger.info("Pruning cached images")
 716 | 
 717 |         do {
 718 |             // Use configured cache directory
 719 |             let cacheDir = (SettingsManager.shared.getCacheDirectory() as NSString)
 720 |                 .expandingTildeInPath
 721 |             let ghcrDir = URL(fileURLWithPath: cacheDir).appendingPathComponent("ghcr")
 722 | 
 723 |             if FileManager.default.fileExists(atPath: ghcrDir.path) {
 724 |                 try FileManager.default.removeItem(at: ghcrDir)
 725 |                 try FileManager.default.createDirectory(
 726 |                     at: ghcrDir, withIntermediateDirectories: true)
 727 |                 Logger.info("Successfully removed cached images")
 728 |             } else {
 729 |                 Logger.info("No cached images found")
 730 |             }
 731 |         } catch {
 732 |             Logger.error("Failed to prune images", metadata: ["error": error.localizedDescription])
 733 |             throw error
 734 |         }
 735 |     }
 736 | 
 737 |     public struct ImageInfo: Codable {
 738 |         public let repository: String
 739 |         public let imageId: String  // This will be the shortened manifest ID
 740 |     }
 741 | 
 742 |     public struct ImageList: Codable {
 743 |         public let local: [ImageInfo]
 744 |         public let remote: [String]  // Keep this for future remote registry support
 745 |     }
 746 | 
 747 |     @MainActor
 748 |     public func getImages(organization: String = "trycua") async throws -> ImageList {
 749 |         Logger.info("Listing local images", metadata: ["organization": organization])
 750 | 
 751 |         let imageContainerRegistry = ImageContainerRegistry(
 752 |             registry: "ghcr.io", organization: organization)
 753 |         let cachedImages = try await imageContainerRegistry.getImages()
 754 | 
 755 |         let imageInfos = cachedImages.map { image in
 756 |             ImageInfo(
 757 |                 repository: image.repository,
 758 |                 imageId: String(image.manifestId.prefix(12))
 759 |             )
 760 |         }
 761 | 
 762 |         ImagesPrinter.print(images: imageInfos.map { "\($0.repository):\($0.imageId)" })
 763 |         return ImageList(local: imageInfos, remote: [])
 764 |     }
 765 | 
 766 |     // MARK: - Settings Management
 767 | 
 768 |     public func getSettings() -> LumeSettings {
 769 |         return SettingsManager.shared.getSettings()
 770 |     }
 771 | 
 772 |     public func setHomeDirectory(_ path: String) throws {
 773 |         // Try to set the home directory in settings
 774 |         try SettingsManager.shared.setHomeDirectory(path: path)
 775 | 
 776 |         // Force recreate home instance to use the new path
 777 |         try home.validateHomeDirectory()
 778 | 
 779 |         Logger.info("Home directory updated", metadata: ["path": path])
 780 |     }
 781 | 
 782 |     // MARK: - VM Location Management
 783 | 
 784 |     public func addLocation(name: String, path: String) throws {
 785 |         Logger.info("Adding VM location", metadata: ["name": name, "path": path])
 786 | 
 787 |         try home.addLocation(name: name, path: path)
 788 | 
 789 |         Logger.info("VM location added successfully", metadata: ["name": name])
 790 |     }
 791 | 
 792 |     public func removeLocation(name: String) throws {
 793 |         Logger.info("Removing VM location", metadata: ["name": name])
 794 | 
 795 |         try home.removeLocation(name: name)
 796 | 
 797 |         Logger.info("VM location removed successfully", metadata: ["name": name])
 798 |     }
 799 | 
 800 |     public func setDefaultLocation(name: String) throws {
 801 |         Logger.info("Setting default VM location", metadata: ["name": name])
 802 | 
 803 |         try home.setDefaultLocation(name: name)
 804 | 
 805 |         Logger.info("Default VM location set successfully", metadata: ["name": name])
 806 |     }
 807 | 
 808 |     public func getLocations() -> [VMLocation] {
 809 |         return home.getLocations()
 810 |     }
 811 | 
 812 |     // MARK: - Cache Directory Management
 813 | 
 814 |     public func setCacheDirectory(path: String) throws {
 815 |         Logger.info("Setting cache directory", metadata: ["path": path])
 816 | 
 817 |         try SettingsManager.shared.setCacheDirectory(path: path)
 818 | 
 819 |         Logger.info("Cache directory updated", metadata: ["path": path])
 820 |     }
 821 | 
 822 |     public func getCacheDirectory() -> String {
 823 |         return SettingsManager.shared.getCacheDirectory()
 824 |     }
 825 | 
 826 |     public func isCachingEnabled() -> Bool {
 827 |         return SettingsManager.shared.isCachingEnabled()
 828 |     }
 829 | 
 830 |     public func setCachingEnabled(_ enabled: Bool) throws {
 831 |         Logger.info("Setting caching enabled", metadata: ["enabled": "\(enabled)"])
 832 | 
 833 |         try SettingsManager.shared.setCachingEnabled(enabled)
 834 | 
 835 |         Logger.info("Caching setting updated", metadata: ["enabled": "\(enabled)"])
 836 |     }
 837 | 
 838 |     // MARK: - Private Helper Methods
 839 | 
 840 |     /// Normalizes a VM name by replacing colons with underscores
 841 |     private func normalizeVMName(name: String) -> String {
 842 |         let components = name.split(separator: ":")
 843 |         return components.count == 2 ? "\(components[0])_\(components[1])" : name
 844 |     }
 845 | 
 846 |     @MainActor
 847 |     private func createTempVMConfig(
 848 |         os: String,
 849 |         cpuCount: Int,
 850 |         memorySize: UInt64,
 851 |         diskSize: UInt64,
 852 |         display: String
 853 |     ) async throws -> VM {
 854 |         let config = try VMConfig(
 855 |             os: os,
 856 |             cpuCount: cpuCount,
 857 |             memorySize: memorySize,
 858 |             diskSize: diskSize,
 859 |             macAddress: VZMACAddress.randomLocallyAdministered().string,
 860 |             display: display
 861 |         )
 862 | 
 863 |         let vmDirContext = VMDirContext(
 864 |             dir: try home.createTempVMDirectory(),
 865 |             config: config,
 866 |             home: home,
 867 |             storage: nil
 868 |         )
 869 | 
 870 |         let imageLoader = os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
 871 |         return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
 872 |     }
 873 | 
 874 |     @MainActor
 875 |     private func loadVM(vmDir: VMDirectory, storage: String?) throws -> VM {
 876 |         // vmDir is now passed directly
 877 |         guard vmDir.initialized() else {
 878 |             throw VMError.notInitialized(vmDir.name) // Use name from vmDir
 879 |         }
 880 | 
 881 |         let config: VMConfig = try vmDir.loadConfig()
 882 |         // Pass the provided storage (which could be a path or named location)
 883 |         let vmDirContext = VMDirContext(
 884 |             dir: vmDir, config: config, home: home, storage: storage
 885 |         )
 886 | 
 887 |         let imageLoader =
 888 |             config.os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
 889 |         return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
 890 |     }
 891 | 
 892 |     // MARK: - Validation Methods
 893 | 
 894 |     private func validateCreateParameters(
 895 |         name: String, os: String, ipsw: String?, storage: String?
 896 |     ) throws {
 897 |         if os.lowercased() == "macos" {
 898 |             guard let ipsw = ipsw else {
 899 |                 throw ValidationError("IPSW path required for macOS VM")
 900 |             }
 901 |             if ipsw != "latest" && !FileManager.default.fileExists(atPath: ipsw) {
 902 |                 throw ValidationError("IPSW file not found")
 903 |             }
 904 |         } else if os.lowercased() == "linux" {
 905 |             if ipsw != nil {
 906 |                 throw ValidationError("IPSW path not supported for Linux VM")
 907 |             }
 908 |         } else {
 909 |             throw ValidationError("Unsupported OS type: \(os)")
 910 |         }
 911 | 
 912 |         let vmDir: VMDirectory = try home.getVMDirectory(name, storage: storage)
 913 |         if vmDir.exists() {
 914 |             throw VMError.alreadyExists(name)
 915 |         }
 916 |     }
 917 | 
 918 |     private func validateSharedDirectories(_ directories: [SharedDirectory]) throws {
 919 |         for dir in directories {
 920 |             var isDirectory: ObjCBool = false
 921 |             guard FileManager.default.fileExists(atPath: dir.hostPath, isDirectory: &isDirectory),
 922 |                 isDirectory.boolValue
 923 |             else {
 924 |                 throw ValidationError(
 925 |                     "Host path does not exist or is not a directory: \(dir.hostPath)")
 926 |             }
 927 |         }
 928 |     }
 929 | 
 930 |     public func validateVMExists(_ name: String, storage: String? = nil) throws -> String? {
 931 |         // If location is specified, only check that location
 932 |         if let storage = storage {
 933 |             // Check if storage is a path by looking for directory separator
 934 |             if storage.contains("/") || storage.contains("\\") {
 935 |                 // Treat as direct path
 936 |                 let vmDir = try home.getVMDirectoryFromPath(name, storagePath: storage)
 937 |                 guard vmDir.initialized() else {
 938 |                     throw VMError.notFound(name)
 939 |                 }
 940 |                 return storage  // Return the path as the location identifier
 941 |             } else {
 942 |                 // Treat as named storage
 943 |                 let vmDir = try home.getVMDirectory(name, storage: storage)
 944 |                 guard vmDir.initialized() else {
 945 |                     throw VMError.notFound(name)
 946 |                 }
 947 |                 return storage
 948 |             }
 949 |         }
 950 | 
 951 |         // If no location specified, try to find the VM in any location
 952 |         let allVMs = try home.getAllVMDirectories()
 953 |         if let foundVM = allVMs.first(where: { $0.directory.name == name }) {
 954 |             // VM found, return its location
 955 |             return foundVM.locationName
 956 |         }
 957 | 
 958 |         // VM not found in any location
 959 |         throw VMError.notFound(name)
 960 |     }
 961 | 
 962 |     private func validateRunParameters(
 963 |         vmDir: VMDirectory, // Changed signature: accept VMDirectory
 964 |         sharedDirectories: [SharedDirectory]?,
 965 |         mount: Path?,
 966 |         usbMassStoragePaths: [Path]? = nil
 967 |     ) throws {
 968 |         // VM existence is confirmed by having vmDir, no need for validateVMExists
 969 |         if let dirs = sharedDirectories {
 970 |             try self.validateSharedDirectories(dirs)
 971 |         }
 972 | 
 973 |         // Validate USB mass storage paths
 974 |         if let usbPaths = usbMassStoragePaths {
 975 |             for path in usbPaths {
 976 |                 if !FileManager.default.fileExists(atPath: path.path) {
 977 |                     throw ValidationError("USB mass storage image not found: \(path.path)")
 978 |                 }
 979 |             }
 980 | 
 981 |             if #available(macOS 15.0, *) {
 982 |                 // USB mass storage is supported
 983 |             } else {
 984 |                 Logger.info(
 985 |                     "USB mass storage devices require macOS 15.0 or later. They will be ignored.")
 986 |             }
 987 |         }
 988 | 
 989 |         // Load config directly from vmDir
 990 |         let vmConfig = try vmDir.loadConfig()
 991 |         switch vmConfig.os.lowercased() {
 992 |         case "macos":
 993 |             if mount != nil {
 994 |                 throw ValidationError(
 995 |                     "Mounting disk images is not supported for macOS VMs. If you are looking to mount a IPSW, please use the --ipsw option in the create command."
 996 |                 )
 997 |             }
 998 |         case "linux":
 999 |             if let mount = mount, !FileManager.default.fileExists(atPath: mount.path) {
1000 |                 throw ValidationError("Mount file not found: \(mount.path)")
1001 |             }
1002 |         default:
1003 |             break
1004 |         }
1005 |     }
1006 | 
1007 |     private func validatePullParameters(
1008 |         image: String,
1009 |         name: String,
1010 |         registry: String,
1011 |         organization: String,
1012 |         storage: String? = nil
1013 |     ) throws {
1014 |         guard !image.isEmpty else {
1015 |             throw ValidationError("Image name cannot be empty")
1016 |         }
1017 |         guard !name.isEmpty else {
1018 |             throw ValidationError("VM name cannot be empty")
1019 |         }
1020 |         guard !registry.isEmpty else {
1021 |             throw ValidationError("Registry cannot be empty")
1022 |         }
1023 |         guard !organization.isEmpty else {
1024 |             throw ValidationError("Organization cannot be empty")
1025 |         }
1026 | 
1027 |         // Determine if storage is a path or a named storage location
1028 |         let vmDir: VMDirectory
1029 |         if let storage = storage, storage.contains("/") || storage.contains("\\") {
1030 |             // Create the base directory if it doesn't exist
1031 |             if !FileManager.default.fileExists(atPath: storage) {
1032 |                 Logger.info("Creating VM storage directory", metadata: ["path": storage])
1033 |                 do {
1034 |                     try FileManager.default.createDirectory(
1035 |                         atPath: storage,
1036 |                         withIntermediateDirectories: true
1037 |                     )
1038 |                 } catch {
1039 |                     throw HomeError.directoryCreationFailed(path: storage)
1040 |                 }
1041 |             }
1042 |             
1043 |             // Use getVMDirectoryFromPath for direct paths
1044 |             vmDir = try home.getVMDirectoryFromPath(name, storagePath: storage)
1045 |         } else {
1046 |             // Use getVMDirectory for named storage locations
1047 |             vmDir = try home.getVMDirectory(name, storage: storage)
1048 |         }
1049 |         
1050 |         if vmDir.exists() {
1051 |             throw VMError.alreadyExists(name)
1052 |         }
1053 |     }
1054 | 
1055 |     private func validatePushParameters(
1056 |         name: String,
1057 |         imageName: String,
1058 |         tags: [String],
1059 |         registry: String,
1060 |         organization: String
1061 |     ) throws {
1062 |         guard !name.isEmpty else {
1063 |             throw ValidationError("VM name cannot be empty")
1064 |         }
1065 |         guard !imageName.isEmpty else {
1066 |             throw ValidationError("Image name cannot be empty")
1067 |         }
1068 |         guard !tags.isEmpty else {
1069 |             throw ValidationError("At least one tag must be provided.")
1070 |         }
1071 |         guard !registry.isEmpty else {
1072 |             throw ValidationError("Registry cannot be empty")
1073 |         }
1074 |         guard !organization.isEmpty else {
1075 |             throw ValidationError("Organization cannot be empty")
1076 |         }
1077 | 
1078 |         // Verify VM exists (this will throw if not found)
1079 |         _ = try self.validateVMExists(name)
1080 |     }
1081 | }
1082 | 
```

--------------------------------------------------------------------------------
/libs/python/computer/computer/interface/generic.py:
--------------------------------------------------------------------------------

```python
  1 | import asyncio
  2 | import json
  3 | import time
  4 | from typing import Any, Dict, List, Optional, Tuple
  5 | from PIL import Image
  6 | 
  7 | import websockets
  8 | import aiohttp
  9 | 
 10 | from ..logger import Logger, LogLevel
 11 | from .base import BaseComputerInterface
 12 | from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image
 13 | from .models import Key, KeyType, MouseButton, CommandResult
 14 | 
 15 | 
 16 | class GenericComputerInterface(BaseComputerInterface):
 17 |     """Generic interface with common functionality for all supported platforms (Windows, Linux, macOS)."""
 18 | 
 19 |     def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None, logger_name: str = "computer.interface.generic"):
 20 |         super().__init__(ip_address, username, password, api_key, vm_name)
 21 |         self._ws = None
 22 |         self._reconnect_task = None
 23 |         self._closed = False
 24 |         self._last_ping = 0
 25 |         self._ping_interval = 5  # Send ping every 5 seconds
 26 |         self._ping_timeout = 120  # Wait 120 seconds for pong response
 27 |         self._reconnect_delay = 1  # Start with 1 second delay
 28 |         self._max_reconnect_delay = 30  # Maximum delay between reconnection attempts
 29 |         self._log_connection_attempts = True  # Flag to control connection attempt logging
 30 |         self._authenticated = False  # Track authentication status
 31 |         self._recv_lock = asyncio.Lock()  # Lock to ensure only one recv at a time
 32 | 
 33 |         # Set logger name for the interface
 34 |         self.logger = Logger(logger_name, LogLevel.NORMAL)
 35 | 
 36 |         # Optional default delay time between commands (in seconds)
 37 |         self.delay = 0.0
 38 | 
 39 |     async def _handle_delay(self, delay: Optional[float] = None):
 40 |         """Handle delay between commands using async sleep.
 41 |         
 42 |         Args:
 43 |             delay: Optional delay in seconds. If None, uses self.delay.
 44 |         """
 45 |         if delay is not None:
 46 |             if isinstance(delay, float) or isinstance(delay, int) and delay > 0:
 47 |                 await asyncio.sleep(delay)
 48 |         elif isinstance(self.delay, float) or isinstance(self.delay, int) and self.delay > 0:
 49 |             await asyncio.sleep(self.delay)
 50 | 
 51 |     @property
 52 |     def ws_uri(self) -> str:
 53 |         """Get the WebSocket URI using the current IP address.
 54 |         
 55 |         Returns:
 56 |             WebSocket URI for the Computer API Server
 57 |         """
 58 |         protocol = "wss" if self.api_key else "ws"
 59 |         port = "8443" if self.api_key else "8000"
 60 |         return f"{protocol}://{self.ip_address}:{port}/ws"
 61 |     
 62 |     @property
 63 |     def rest_uri(self) -> str:
 64 |         """Get the REST URI using the current IP address.
 65 |         
 66 |         Returns:
 67 |             REST URI for the Computer API Server
 68 |         """
 69 |         protocol = "https" if self.api_key else "http"
 70 |         port = "8443" if self.api_key else "8000"
 71 |         return f"{protocol}://{self.ip_address}:{port}/cmd"
 72 | 
 73 |     # Mouse actions
 74 |     async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None:
 75 |         await self._send_command("mouse_down", {"x": x, "y": y, "button": button})
 76 |         await self._handle_delay(delay)
 77 |     
 78 |     async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None:
 79 |         await self._send_command("mouse_up", {"x": x, "y": y, "button": button})
 80 |         await self._handle_delay(delay)
 81 |     
 82 |     async def left_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None:
 83 |         await self._send_command("left_click", {"x": x, "y": y})
 84 |         await self._handle_delay(delay)
 85 | 
 86 |     async def right_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None:
 87 |         await self._send_command("right_click", {"x": x, "y": y})
 88 |         await self._handle_delay(delay)
 89 | 
 90 |     async def double_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None:
 91 |         await self._send_command("double_click", {"x": x, "y": y})
 92 |         await self._handle_delay(delay)
 93 | 
 94 |     async def move_cursor(self, x: int, y: int, delay: Optional[float] = None) -> None:
 95 |         await self._send_command("move_cursor", {"x": x, "y": y})
 96 |         await self._handle_delay(delay)
 97 | 
 98 |     async def drag_to(self, x: int, y: int, button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None:
 99 |         await self._send_command(
100 |             "drag_to", {"x": x, "y": y, "button": button, "duration": duration}
101 |         )
102 |         await self._handle_delay(delay)
103 | 
104 |     async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None:
105 |         await self._send_command(
106 |             "drag", {"path": path, "button": button, "duration": duration}
107 |         )
108 |         await self._handle_delay(delay)
109 | 
110 |     # Keyboard Actions
111 |     async def key_down(self, key: "KeyType", delay: Optional[float] = None) -> None:
112 |         await self._send_command("key_down", {"key": key})
113 |         await self._handle_delay(delay)
114 |     
115 |     async def key_up(self, key: "KeyType", delay: Optional[float] = None) -> None:
116 |         await self._send_command("key_up", {"key": key})
117 |         await self._handle_delay(delay)
118 |     
119 |     async def type_text(self, text: str, delay: Optional[float] = None) -> None:
120 |         # Temporary fix for https://github.com/trycua/cua/issues/165
121 |         # Check if text contains Unicode characters
122 |         if any(ord(char) > 127 for char in text):
123 |             # For Unicode text, use clipboard and paste
124 |             await self.set_clipboard(text)
125 |             await self.hotkey(Key.COMMAND, 'v')
126 |         else:
127 |             # For ASCII text, use the regular typing method
128 |             await self._send_command("type_text", {"text": text})
129 |         await self._handle_delay(delay)
130 | 
131 |     async def press(self, key: "KeyType", delay: Optional[float] = None) -> None:
132 |         """Press a single key.
133 | 
134 |         Args:
135 |             key: The key to press. Can be any of:
136 |                 - A Key enum value (recommended), e.g. Key.PAGE_DOWN
137 |                 - A direct key value string, e.g. 'pagedown'
138 |                 - A single character string, e.g. 'a'
139 | 
140 |         Examples:
141 |             ```python
142 |             # Using enum (recommended)
143 |             await interface.press(Key.PAGE_DOWN)
144 |             await interface.press(Key.ENTER)
145 | 
146 |             # Using direct values
147 |             await interface.press('pagedown')
148 |             await interface.press('enter')
149 | 
150 |             # Using single characters
151 |             await interface.press('a')
152 |             ```
153 | 
154 |         Raises:
155 |             ValueError: If the key type is invalid or the key is not recognized
156 |         """
157 |         if isinstance(key, Key):
158 |             actual_key = key.value
159 |         elif isinstance(key, str):
160 |             # Try to convert to enum if it matches a known key
161 |             key_or_enum = Key.from_string(key)
162 |             actual_key = key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum
163 |         else:
164 |             raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.")
165 | 
166 |         await self._send_command("press_key", {"key": actual_key})
167 |         await self._handle_delay(delay)
168 | 
169 |     async def press_key(self, key: "KeyType", delay: Optional[float] = None) -> None:
170 |         """DEPRECATED: Use press() instead.
171 | 
172 |         This method is kept for backward compatibility but will be removed in a future version.
173 |         Please use the press() method instead.
174 |         """
175 |         await self.press(key, delay)
176 | 
177 |     async def hotkey(self, *keys: "KeyType", delay: Optional[float] = None) -> None:
178 |         """Press multiple keys simultaneously.
179 | 
180 |         Args:
181 |             *keys: Multiple keys to press simultaneously. Each key can be any of:
182 |                 - A Key enum value (recommended), e.g. Key.COMMAND
183 |                 - A direct key value string, e.g. 'command'
184 |                 - A single character string, e.g. 'a'
185 | 
186 |         Examples:
187 |             ```python
188 |             # Using enums (recommended)
189 |             await interface.hotkey(Key.COMMAND, Key.C)  # Copy
190 |             await interface.hotkey(Key.COMMAND, Key.V)  # Paste
191 | 
192 |             # Using mixed formats
193 |             await interface.hotkey(Key.COMMAND, 'a')  # Select all
194 |             ```
195 | 
196 |         Raises:
197 |             ValueError: If any key type is invalid or not recognized
198 |         """
199 |         actual_keys = []
200 |         for key in keys:
201 |             if isinstance(key, Key):
202 |                 actual_keys.append(key.value)
203 |             elif isinstance(key, str):
204 |                 # Try to convert to enum if it matches a known key
205 |                 key_or_enum = Key.from_string(key)
206 |                 actual_keys.append(key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum)
207 |             else:
208 |                 raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.")
209 |         
210 |         await self._send_command("hotkey", {"keys": actual_keys})
211 |         await self._handle_delay(delay)
212 | 
213 |     # Scrolling Actions
214 |     async def scroll(self, x: int, y: int, delay: Optional[float] = None) -> None:
215 |         await self._send_command("scroll", {"x": x, "y": y})
216 |         await self._handle_delay(delay)
217 |     
218 |     async def scroll_down(self, clicks: int = 1, delay: Optional[float] = None) -> None:
219 |         await self._send_command("scroll_down", {"clicks": clicks})
220 |         await self._handle_delay(delay)
221 |     
222 |     async def scroll_up(self, clicks: int = 1, delay: Optional[float] = None) -> None:
223 |         await self._send_command("scroll_up", {"clicks": clicks})
224 |         await self._handle_delay(delay)
225 | 
226 |     # Screen actions
227 |     async def screenshot(
228 |         self,
229 |         boxes: Optional[List[Tuple[int, int, int, int]]] = None,
230 |         box_color: str = "#FF0000",
231 |         box_thickness: int = 2,
232 |         scale_factor: float = 1.0,
233 |     ) -> bytes:
234 |         """Take a screenshot with optional box drawing and scaling.
235 | 
236 |         Args:
237 |             boxes: Optional list of (x, y, width, height) tuples defining boxes to draw in screen coordinates
238 |             box_color: Color of the boxes in hex format (default: "#FF0000" red)
239 |             box_thickness: Thickness of the box borders in pixels (default: 2)
240 |             scale_factor: Factor to scale the final image by (default: 1.0)
241 |                          Use > 1.0 to enlarge, < 1.0 to shrink (e.g., 0.5 for half size, 2.0 for double)
242 | 
243 |         Returns:
244 |             bytes: The screenshot image data, optionally with boxes drawn on it and scaled
245 |         """
246 |         result = await self._send_command("screenshot")
247 |         if not result.get("image_data"):
248 |             raise RuntimeError("Failed to take screenshot, no image data received from server")
249 | 
250 |         screenshot = decode_base64_image(result["image_data"])
251 | 
252 |         if boxes:
253 |             # Get the natural scaling between screen and screenshot
254 |             screen_size = await self.get_screen_size()
255 |             screenshot_width, screenshot_height = bytes_to_image(screenshot).size
256 |             width_scale = screenshot_width / screen_size["width"]
257 |             height_scale = screenshot_height / screen_size["height"]
258 | 
259 |             # Scale box coordinates from screen space to screenshot space
260 |             for box in boxes:
261 |                 scaled_box = (
262 |                     int(box[0] * width_scale),  # x
263 |                     int(box[1] * height_scale),  # y
264 |                     int(box[2] * width_scale),  # width
265 |                     int(box[3] * height_scale),  # height
266 |                 )
267 |                 screenshot = draw_box(
268 |                     screenshot,
269 |                     x=scaled_box[0],
270 |                     y=scaled_box[1],
271 |                     width=scaled_box[2],
272 |                     height=scaled_box[3],
273 |                     color=box_color,
274 |                     thickness=box_thickness,
275 |                 )
276 | 
277 |         if scale_factor != 1.0:
278 |             screenshot = resize_image(screenshot, scale_factor)
279 | 
280 |         return screenshot
281 | 
282 |     async def get_screen_size(self) -> Dict[str, int]:
283 |         result = await self._send_command("get_screen_size")
284 |         if result["success"] and result["size"]:
285 |             return result["size"]
286 |         raise RuntimeError("Failed to get screen size")
287 | 
288 |     async def get_cursor_position(self) -> Dict[str, int]:
289 |         result = await self._send_command("get_cursor_position")
290 |         if result["success"] and result["position"]:
291 |             return result["position"]
292 |         raise RuntimeError("Failed to get cursor position")
293 | 
294 |     # Clipboard Actions
295 |     async def copy_to_clipboard(self) -> str:
296 |         result = await self._send_command("copy_to_clipboard")
297 |         if result["success"] and result["content"]:
298 |             return result["content"]
299 |         raise RuntimeError("Failed to get clipboard content")
300 | 
301 |     async def set_clipboard(self, text: str) -> None:
302 |         await self._send_command("set_clipboard", {"text": text})
303 | 
304 |     # File Operations
305 |     async def _write_bytes_chunked(self, path: str, content: bytes, append: bool = False, chunk_size: int = 1024 * 1024) -> None:
306 |         """Write large files in chunks to avoid memory issues."""
307 |         total_size = len(content)
308 |         current_offset = 0
309 |         
310 |         while current_offset < total_size:
311 |             chunk_end = min(current_offset + chunk_size, total_size)
312 |             chunk_data = content[current_offset:chunk_end]
313 |             
314 |             # First chunk uses the original append flag, subsequent chunks always append
315 |             chunk_append = append if current_offset == 0 else True
316 |             
317 |             result = await self._send_command("write_bytes", {
318 |                 "path": path,
319 |                 "content_b64": encode_base64_image(chunk_data),
320 |                 "append": chunk_append
321 |             })
322 |             
323 |             if not result.get("success", False):
324 |                 raise RuntimeError(result.get("error", "Failed to write file chunk"))
325 |             
326 |             current_offset = chunk_end
327 | 
328 |     async def write_bytes(self, path: str, content: bytes, append: bool = False) -> None:
329 |         # For large files, use chunked writing
330 |         if len(content) > 5 * 1024 * 1024:  # 5MB threshold
331 |             await self._write_bytes_chunked(path, content, append)
332 |             return
333 |         
334 |         result = await self._send_command("write_bytes", {"path": path, "content_b64": encode_base64_image(content), "append": append})
335 |         if not result.get("success", False):
336 |             raise RuntimeError(result.get("error", "Failed to write file"))
337 | 
338 |     async def _read_bytes_chunked(self, path: str, offset: int, total_length: int, chunk_size: int = 1024 * 1024) -> bytes:
339 |         """Read large files in chunks to avoid memory issues."""
340 |         chunks = []
341 |         current_offset = offset
342 |         remaining = total_length
343 |         
344 |         while remaining > 0:
345 |             read_size = min(chunk_size, remaining)
346 |             result = await self._send_command("read_bytes", {
347 |                 "path": path,
348 |                 "offset": current_offset,
349 |                 "length": read_size
350 |             })
351 |             
352 |             if not result.get("success", False):
353 |                 raise RuntimeError(result.get("error", "Failed to read file chunk"))
354 |             
355 |             content_b64 = result.get("content_b64", "")
356 |             chunk_data = decode_base64_image(content_b64)
357 |             chunks.append(chunk_data)
358 |             
359 |             current_offset += read_size
360 |             remaining -= read_size
361 |         
362 |         return b''.join(chunks)
363 | 
364 |     async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes:
365 |         # For large files, use chunked reading
366 |         if length is None:
367 |             # Get file size first to determine if we need chunking
368 |             file_size = await self.get_file_size(path)
369 |             # If file is larger than 5MB, read in chunks
370 |             if file_size > 5 * 1024 * 1024:  # 5MB threshold
371 |                 return await self._read_bytes_chunked(path, offset, file_size - offset if offset > 0 else file_size)
372 |         
373 |         result = await self._send_command("read_bytes", {
374 |             "path": path, 
375 |             "offset": offset, 
376 |             "length": length
377 |         })
378 |         if not result.get("success", False):
379 |             raise RuntimeError(result.get("error", "Failed to read file"))
380 |         content_b64 = result.get("content_b64", "")
381 |         return decode_base64_image(content_b64)
382 | 
383 |     async def read_text(self, path: str, encoding: str = 'utf-8') -> str:
384 |         """Read text from a file with specified encoding.
385 |         
386 |         Args:
387 |             path: Path to the file to read
388 |             encoding: Text encoding to use (default: 'utf-8')
389 |             
390 |         Returns:
391 |             str: The decoded text content of the file
392 |         """
393 |         content_bytes = await self.read_bytes(path)
394 |         return content_bytes.decode(encoding)
395 | 
396 |     async def write_text(self, path: str, content: str, encoding: str = 'utf-8', append: bool = False) -> None:
397 |         """Write text to a file with specified encoding.
398 |         
399 |         Args:
400 |             path: Path to the file to write
401 |             content: Text content to write
402 |             encoding: Text encoding to use (default: 'utf-8')
403 |             append: Whether to append to the file instead of overwriting
404 |         """
405 |         content_bytes = content.encode(encoding)
406 |         await self.write_bytes(path, content_bytes, append)
407 | 
408 |     async def get_file_size(self, path: str) -> int:
409 |         result = await self._send_command("get_file_size", {"path": path})
410 |         if not result.get("success", False):
411 |             raise RuntimeError(result.get("error", "Failed to get file size"))
412 |         return result.get("size", 0)
413 | 
414 |     async def file_exists(self, path: str) -> bool:
415 |         result = await self._send_command("file_exists", {"path": path})
416 |         return result.get("exists", False)
417 | 
418 |     async def directory_exists(self, path: str) -> bool:
419 |         result = await self._send_command("directory_exists", {"path": path})
420 |         return result.get("exists", False)
421 | 
422 |     async def create_dir(self, path: str) -> None:
423 |         result = await self._send_command("create_dir", {"path": path})
424 |         if not result.get("success", False):
425 |             raise RuntimeError(result.get("error", "Failed to create directory"))
426 | 
427 |     async def delete_file(self, path: str) -> None:
428 |         result = await self._send_command("delete_file", {"path": path})
429 |         if not result.get("success", False):
430 |             raise RuntimeError(result.get("error", "Failed to delete file"))
431 | 
432 |     async def delete_dir(self, path: str) -> None:
433 |         result = await self._send_command("delete_dir", {"path": path})
434 |         if not result.get("success", False):
435 |             raise RuntimeError(result.get("error", "Failed to delete directory"))
436 | 
437 |     async def list_dir(self, path: str) -> list[str]:
438 |         result = await self._send_command("list_dir", {"path": path})
439 |         if not result.get("success", False):
440 |             raise RuntimeError(result.get("error", "Failed to list directory"))
441 |         return result.get("files", [])
442 | 
443 |     # Command execution
444 |     async def run_command(self, command: str) -> CommandResult:
445 |         result = await self._send_command("run_command", {"command": command})
446 |         if not result.get("success", False):
447 |             raise RuntimeError(result.get("error", "Failed to run command"))
448 |         return CommandResult(
449 |             stdout=result.get("stdout", ""),
450 |             stderr=result.get("stderr", ""),
451 |             returncode=result.get("return_code", 0)
452 |         )
453 | 
454 |     # Accessibility Actions
455 |     async def get_accessibility_tree(self) -> Dict[str, Any]:
456 |         """Get the accessibility tree of the current screen."""
457 |         result = await self._send_command("get_accessibility_tree")
458 |         if not result.get("success", False):
459 |             raise RuntimeError(result.get("error", "Failed to get accessibility tree"))
460 |         return result
461 |     
462 |     async def get_active_window_bounds(self) -> Dict[str, int]:
463 |         """Get the bounds of the currently active window."""
464 |         result = await self._send_command("get_active_window_bounds")
465 |         if result["success"] and result["bounds"]:
466 |             return result["bounds"]
467 |         raise RuntimeError("Failed to get active window bounds")
468 | 
469 |     async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
470 |         """Convert screenshot coordinates to screen coordinates.
471 | 
472 |         Args:
473 |             x: X coordinate in screenshot space
474 |             y: Y coordinate in screenshot space
475 | 
476 |         Returns:
477 |             tuple[float, float]: (x, y) coordinates in screen space
478 |         """
479 |         screen_size = await self.get_screen_size()
480 |         screenshot = await self.screenshot()
481 |         screenshot_img = bytes_to_image(screenshot)
482 |         screenshot_width, screenshot_height = screenshot_img.size
483 | 
484 |         # Calculate scaling factors
485 |         width_scale = screen_size["width"] / screenshot_width
486 |         height_scale = screen_size["height"] / screenshot_height
487 | 
488 |         # Convert coordinates
489 |         screen_x = x * width_scale
490 |         screen_y = y * height_scale
491 | 
492 |         return screen_x, screen_y
493 | 
494 |     async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]:
495 |         """Convert screen coordinates to screenshot coordinates.
496 | 
497 |         Args:
498 |             x: X coordinate in screen space
499 |             y: Y coordinate in screen space
500 | 
501 |         Returns:
502 |             tuple[float, float]: (x, y) coordinates in screenshot space
503 |         """
504 |         screen_size = await self.get_screen_size()
505 |         screenshot = await self.screenshot()
506 |         screenshot_img = bytes_to_image(screenshot)
507 |         screenshot_width, screenshot_height = screenshot_img.size
508 | 
509 |         # Calculate scaling factors
510 |         width_scale = screenshot_width / screen_size["width"]
511 |         height_scale = screenshot_height / screen_size["height"]
512 | 
513 |         # Convert coordinates
514 |         screenshot_x = x * width_scale
515 |         screenshot_y = y * height_scale
516 | 
517 |         return screenshot_x, screenshot_y
518 | 
519 |     # Websocket Methods
520 |     async def _keep_alive(self):
521 |         """Keep the WebSocket connection alive with automatic reconnection."""
522 |         retry_count = 0
523 |         max_log_attempts = 1  # Only log the first attempt at INFO level
524 |         log_interval = 500  # Then log every 500th attempt (significantly increased from 30)
525 |         last_warning_time = 0
526 |         min_warning_interval = 30  # Minimum seconds between connection lost warnings
527 |         min_retry_delay = 0.5  # Minimum delay between connection attempts (500ms)
528 | 
529 |         while not self._closed:
530 |             try:
531 |                 if self._ws is None or (
532 |                     self._ws and self._ws.state == websockets.protocol.State.CLOSED
533 |                 ):
534 |                     try:
535 |                         retry_count += 1
536 | 
537 |                         # Add a minimum delay between connection attempts to avoid flooding
538 |                         if retry_count > 1:
539 |                             await asyncio.sleep(min_retry_delay)
540 | 
541 |                         # Only log the first attempt at INFO level, then every Nth attempt
542 |                         if retry_count == 1:
543 |                             self.logger.info(f"Attempting WebSocket connection to {self.ws_uri}")
544 |                         elif retry_count % log_interval == 0:
545 |                             self.logger.info(
546 |                                 f"Still attempting WebSocket connection (attempt {retry_count})..."
547 |                             )
548 |                         else:
549 |                             # All other attempts are logged at DEBUG level
550 |                             self.logger.debug(
551 |                                 f"Attempting WebSocket connection to {self.ws_uri} (attempt {retry_count})"
552 |                             )
553 | 
554 |                         self._ws = await asyncio.wait_for(
555 |                             websockets.connect(
556 |                                 self.ws_uri,
557 |                                 max_size=1024 * 1024 * 10,  # 10MB limit
558 |                                 max_queue=32,
559 |                                 ping_interval=self._ping_interval,
560 |                                 ping_timeout=self._ping_timeout,
561 |                                 close_timeout=5,
562 |                                 compression=None,  # Disable compression to reduce overhead
563 |                             ),
564 |                             timeout=120,
565 |                         )
566 |                         self.logger.info("WebSocket connection established")
567 |                         
568 |                         # If api_key and vm_name are provided, perform authentication handshake
569 |                         if self.api_key and self.vm_name:
570 |                             self.logger.info("Performing authentication handshake...")
571 |                             auth_message = {
572 |                                 "command": "authenticate",
573 |                                 "params": {
574 |                                     "api_key": self.api_key,
575 |                                     "container_name": self.vm_name
576 |                                 }
577 |                             }
578 |                             await self._ws.send(json.dumps(auth_message))
579 |                             
580 |                             # Wait for authentication response
581 |                             async with self._recv_lock:
582 |                                 auth_response = await asyncio.wait_for(self._ws.recv(), timeout=10)
583 |                             auth_result = json.loads(auth_response)
584 |                             
585 |                             if not auth_result.get("success"):
586 |                                 error_msg = auth_result.get("error", "Authentication failed")
587 |                                 self.logger.error(f"Authentication failed: {error_msg}")
588 |                                 await self._ws.close()
589 |                                 self._ws = None
590 |                                 raise ConnectionError(f"Authentication failed: {error_msg}")
591 |                             
592 |                             self.logger.info("Authentication successful")
593 |                         
594 |                         self._reconnect_delay = 1  # Reset reconnect delay on successful connection
595 |                         self._last_ping = time.time()
596 |                         retry_count = 0  # Reset retry count on successful connection
597 |                     except (asyncio.TimeoutError, websockets.exceptions.WebSocketException) as e:
598 |                         next_retry = self._reconnect_delay
599 | 
600 |                         # Only log the first error at WARNING level, then every Nth attempt
601 |                         if retry_count == 1:
602 |                             self.logger.warning(
603 |                                 f"Computer API Server not ready yet. Will retry automatically."
604 |                             )
605 |                         elif retry_count % log_interval == 0:
606 |                             self.logger.warning(
607 |                                 f"Still waiting for Computer API Server (attempt {retry_count})..."
608 |                             )
609 |                         else:
610 |                             # All other errors are logged at DEBUG level
611 |                             self.logger.debug(f"Connection attempt {retry_count} failed: {e}")
612 | 
613 |                         if self._ws:
614 |                             try:
615 |                                 await self._ws.close()
616 |                             except:
617 |                                 pass
618 |                         self._ws = None
619 | 
620 |                         # Use exponential backoff for connection retries
621 |                         await asyncio.sleep(self._reconnect_delay)
622 |                         self._reconnect_delay = min(
623 |                             self._reconnect_delay * 2, self._max_reconnect_delay
624 |                         )
625 |                         continue
626 | 
627 |                 # Regular ping to check connection
628 |                 if self._ws and self._ws.state == websockets.protocol.State.OPEN:
629 |                     try:
630 |                         if time.time() - self._last_ping >= self._ping_interval:
631 |                             pong_waiter = await self._ws.ping()
632 |                             await asyncio.wait_for(pong_waiter, timeout=self._ping_timeout)
633 |                             self._last_ping = time.time()
634 |                     except Exception as e:
635 |                         self.logger.debug(f"Ping failed: {e}")
636 |                         if self._ws:
637 |                             try:
638 |                                 await self._ws.close()
639 |                             except:
640 |                                 pass
641 |                         self._ws = None
642 |                         continue
643 | 
644 |                 await asyncio.sleep(1)
645 | 
646 |             except Exception as e:
647 |                 current_time = time.time()
648 |                 # Only log connection lost warnings at most once every min_warning_interval seconds
649 |                 if current_time - last_warning_time >= min_warning_interval:
650 |                     self.logger.warning(
651 |                         f"Computer API Server connection lost. Will retry automatically."
652 |                     )
653 |                     last_warning_time = current_time
654 |                 else:
655 |                     # Log at debug level instead
656 |                     self.logger.debug(f"Connection lost: {e}")
657 | 
658 |                 if self._ws:
659 |                     try:
660 |                         await self._ws.close()
661 |                     except:
662 |                         pass
663 |                 self._ws = None
664 |     
665 |     async def _ensure_connection(self):
666 |         """Ensure WebSocket connection is established."""
667 |         if self._reconnect_task is None or self._reconnect_task.done():
668 |             self._reconnect_task = asyncio.create_task(self._keep_alive())
669 | 
670 |         retry_count = 0
671 |         max_retries = 5
672 | 
673 |         while retry_count < max_retries:
674 |             try:
675 |                 if self._ws and self._ws.state == websockets.protocol.State.OPEN:
676 |                     return
677 |                 retry_count += 1
678 |                 await asyncio.sleep(1)
679 |             except Exception as e:
680 |                 # Only log at ERROR level for the last retry attempt
681 |                 if retry_count == max_retries - 1:
682 |                     self.logger.error(
683 |                         f"Persistent connection check error after {retry_count} attempts: {e}"
684 |                     )
685 |                 else:
686 |                     self.logger.debug(f"Connection check error (attempt {retry_count}): {e}")
687 |                 retry_count += 1
688 |                 await asyncio.sleep(1)
689 |                 continue
690 | 
691 |         raise ConnectionError("Failed to establish WebSocket connection after multiple retries")
692 | 
693 |     async def _send_command_ws(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]:
694 |         """Send command through WebSocket."""
695 |         max_retries = 3
696 |         retry_count = 0
697 |         last_error = None
698 | 
699 |         # Acquire lock to ensure only one command is processed at a time
700 |         self.logger.debug(f"Acquired lock for command: {command}")
701 |         while retry_count < max_retries:
702 |             try:
703 |                 await self._ensure_connection()
704 |                 if not self._ws:
705 |                     raise ConnectionError("WebSocket connection is not established")
706 | 
707 |                 message = {"command": command, "params": params or {}}
708 |                 await self._ws.send(json.dumps(message))
709 |                 async with self._recv_lock:
710 |                     response = await asyncio.wait_for(self._ws.recv(), timeout=120)
711 |                 self.logger.debug(f"Completed command: {command}")
712 |                 return json.loads(response)
713 |             except Exception as e:
714 |                 last_error = e
715 |                 retry_count += 1
716 |                 if retry_count < max_retries:
717 |                     # Only log at debug level for intermediate retries
718 |                     self.logger.debug(
719 |                         f"Command '{command}' failed (attempt {retry_count}/{max_retries}): {e}"
720 |                     )
721 |                     await asyncio.sleep(1)
722 |                     continue
723 |                 else:
724 |                     # Only log at error level for the final failure
725 |                     self.logger.error(
726 |                         f"Failed to send command '{command}' after {max_retries} retries"
727 |                     )
728 |                     self.logger.debug(f"Command failure details: {e}")
729 |                     raise
730 | 
731 |         raise last_error if last_error else RuntimeError("Failed to send command")
732 | 
733 |     async def _send_command_rest(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]:
734 |         """Send command through REST API without retries or connection management."""
735 |         try:
736 |             # Prepare the request payload
737 |             payload = {"command": command, "params": params or {}}
738 |             
739 |             # Prepare headers
740 |             headers = {"Content-Type": "application/json"}
741 |             if self.api_key:
742 |                 headers["X-API-Key"] = self.api_key
743 |             if self.vm_name:
744 |                 headers["X-Container-Name"] = self.vm_name
745 |             
746 |             # Send the request
747 |             async with aiohttp.ClientSession() as session:
748 |                 async with session.post(
749 |                     self.rest_uri,
750 |                     json=payload,
751 |                     headers=headers
752 |                 ) as response:
753 |                     # Get the response text
754 |                     response_text = await response.text()
755 |                     
756 |                     # Trim whitespace
757 |                     response_text = response_text.strip()
758 |                     
759 |                     # Check if it starts with "data: "
760 |                     if response_text.startswith("data: "):
761 |                         # Extract everything after "data: "
762 |                         json_str = response_text[6:]  # Remove "data: " prefix
763 |                         try:
764 |                             return json.loads(json_str)
765 |                         except json.JSONDecodeError:
766 |                             return {
767 |                                 "success": False,
768 |                                 "error": "Server returned malformed response",
769 |                                 "message": response_text
770 |                             }
771 |                     else:
772 |                         # Return error response
773 |                         return {
774 |                             "success": False,
775 |                             "error": "Server returned malformed response",
776 |                             "message": response_text
777 |                         }
778 |                         
779 |         except Exception as e:
780 |             return {
781 |                 "success": False,
782 |                 "error": "Request failed",
783 |                 "message": str(e)
784 |             }
785 | 
786 |     async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]:
787 |         """Send command using REST API with WebSocket fallback."""
788 |         # Try REST API first
789 |         result = await self._send_command_rest(command, params)
790 |         
791 |         # If REST failed with "Request failed", try WebSocket as fallback
792 |         if not result.get("success", True) and (result.get("error") == "Request failed" or result.get("error") == "Server returned malformed response"):
793 |             self.logger.warning(f"REST API failed for command '{command}', trying WebSocket fallback")
794 |             try:
795 |                 return await self._send_command_ws(command, params)
796 |             except Exception as e:
797 |                 self.logger.error(f"WebSocket fallback also failed: {e}")
798 |                 # Return the original REST error
799 |                 return result
800 |         
801 |         return result
802 | 
803 |     async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0):
804 |         """Wait for Computer API Server to be ready by testing version command."""
805 | 
806 |         # Check if REST API is available
807 |         try:
808 |             result = await self._send_command_rest("version", {})
809 |             assert result.get("success", True)
810 |         except Exception as e:
811 |             self.logger.debug(f"REST API failed for command 'version', trying WebSocket fallback: {e}")
812 |             try:
813 |                 await self._wait_for_ready_ws(timeout, interval)
814 |                 return
815 |             except Exception as e:
816 |                 self.logger.debug(f"WebSocket fallback also failed: {e}")
817 |                 raise e
818 | 
819 |         start_time = time.time()
820 |         last_error = None
821 |         attempt_count = 0
822 |         progress_interval = 10  # Log progress every 10 seconds
823 |         last_progress_time = start_time
824 | 
825 |         try:
826 |             self.logger.info(
827 |                 f"Waiting for Computer API Server to be ready (timeout: {timeout}s)..."
828 |             )
829 | 
830 |             # Wait for the server to respond to get_screen_size command
831 |             while time.time() - start_time < timeout:
832 |                 try:
833 |                     attempt_count += 1
834 |                     current_time = time.time()
835 | 
836 |                     # Log progress periodically without flooding logs
837 |                     if current_time - last_progress_time >= progress_interval:
838 |                         elapsed = current_time - start_time
839 |                         self.logger.info(
840 |                             f"Still waiting for Computer API Server... (elapsed: {elapsed:.1f}s, attempts: {attempt_count})"
841 |                         )
842 |                         last_progress_time = current_time
843 | 
844 |                     # Test the server with a simple get_screen_size command
845 |                     result = await self._send_command("get_screen_size")
846 |                     if result.get("success", False):
847 |                         elapsed = time.time() - start_time
848 |                         self.logger.info(
849 |                             f"Computer API Server is ready (after {elapsed:.1f}s, {attempt_count} attempts)"
850 |                         )
851 |                         return  # Server is ready
852 |                     else:
853 |                         last_error = result.get("error", "Unknown error")
854 |                         self.logger.debug(f"Initial connection command failed: {last_error}")
855 | 
856 |                 except Exception as e:
857 |                     last_error = e
858 |                     self.logger.debug(f"Connection attempt {attempt_count} failed: {e}")
859 | 
860 |                 # Wait before trying again
861 |                 await asyncio.sleep(interval)
862 | 
863 |             # If we get here, we've timed out
864 |             error_msg = f"Could not connect to {self.ip_address} after {timeout} seconds"
865 |             if last_error:
866 |                 error_msg += f": {str(last_error)}"
867 |             self.logger.error(error_msg)
868 |             raise TimeoutError(error_msg)
869 | 
870 |         except Exception as e:
871 |             if isinstance(e, TimeoutError):
872 |                 raise
873 |             error_msg = f"Error while waiting for server: {str(e)}"
874 |             self.logger.error(error_msg)
875 |             raise RuntimeError(error_msg)
876 | 
877 |     async def _wait_for_ready_ws(self, timeout: int = 60, interval: float = 1.0):
878 |         """Wait for WebSocket connection to become available."""
879 |         start_time = time.time()
880 |         last_error = None
881 |         attempt_count = 0
882 |         progress_interval = 10  # Log progress every 10 seconds
883 |         last_progress_time = start_time
884 | 
885 |         # Disable detailed logging for connection attempts
886 |         self._log_connection_attempts = False
887 | 
888 |         try:
889 |             self.logger.info(
890 |                 f"Waiting for Computer API Server to be ready (timeout: {timeout}s)..."
891 |             )
892 | 
893 |             # Start the keep-alive task if it's not already running
894 |             if self._reconnect_task is None or self._reconnect_task.done():
895 |                 self._reconnect_task = asyncio.create_task(self._keep_alive())
896 | 
897 |             # Wait for the connection to be established
898 |             while time.time() - start_time < timeout:
899 |                 try:
900 |                     attempt_count += 1
901 |                     current_time = time.time()
902 | 
903 |                     # Log progress periodically without flooding logs
904 |                     if current_time - last_progress_time >= progress_interval:
905 |                         elapsed = current_time - start_time
906 |                         self.logger.info(
907 |                             f"Still waiting for Computer API Server... (elapsed: {elapsed:.1f}s, attempts: {attempt_count})"
908 |                         )
909 |                         last_progress_time = current_time
910 | 
911 |                     # Check if we have a connection
912 |                     if self._ws and self._ws.state == websockets.protocol.State.OPEN:
913 |                         # Test the connection with a simple command
914 |                         try:
915 |                             await self._send_command_ws("get_screen_size")
916 |                             elapsed = time.time() - start_time
917 |                             self.logger.info(
918 |                                 f"Computer API Server is ready (after {elapsed:.1f}s, {attempt_count} attempts)"
919 |                             )
920 |                             return  # Connection is fully working
921 |                         except Exception as e:
922 |                             last_error = e
923 |                             self.logger.debug(f"Connection test failed: {e}")
924 | 
925 |                     # Wait before trying again
926 |                     await asyncio.sleep(interval)
927 | 
928 |                 except Exception as e:
929 |                     last_error = e
930 |                     self.logger.debug(f"Connection attempt {attempt_count} failed: {e}")
931 |                     await asyncio.sleep(interval)
932 | 
933 |             # If we get here, we've timed out
934 |             error_msg = f"Could not connect to {self.ip_address} after {timeout} seconds"
935 |             if last_error:
936 |                 error_msg += f": {str(last_error)}"
937 |             self.logger.error(error_msg)
938 |             raise TimeoutError(error_msg)
939 |         finally:
940 |             # Reset to default logging behavior
941 |             self._log_connection_attempts = False
942 | 
943 |     def close(self):
944 |         """Close WebSocket connection.
945 | 
946 |         Note: In host computer server mode, we leave the connection open
947 |         to allow other clients to connect to the same server. The server
948 |         will handle cleaning up idle connections.
949 |         """
950 |         # Only cancel the reconnect task
951 |         if self._reconnect_task:
952 |             self._reconnect_task.cancel()
953 | 
954 |         # Don't set closed flag or close websocket by default
955 |         # This allows the server to stay connected for other clients
956 |         # self._closed = True
957 |         # if self._ws:
958 |         #     asyncio.create_task(self._ws.close())
959 |         #     self._ws = None
960 |     
961 |     def force_close(self):
962 |         """Force close the WebSocket connection.
963 | 
964 |         This method should be called when you want to completely
965 |         shut down the connection, not just for regular cleanup.
966 |         """
967 |         self._closed = True
968 |         if self._reconnect_task:
969 |             self._reconnect_task.cancel()
970 |         if self._ws:
971 |             asyncio.create_task(self._ws.close())
972 |             self._ws = None
973 | 
974 | 
```

--------------------------------------------------------------------------------
/libs/python/computer/computer/computer.py:
--------------------------------------------------------------------------------

```python
  1 | from typing import Optional, List, Literal, Dict, Any, Union, TYPE_CHECKING, cast
  2 | import asyncio
  3 | from .models import Computer as ComputerConfig, Display
  4 | from .interface.factory import InterfaceFactory
  5 | import time
  6 | from PIL import Image
  7 | import io
  8 | import re
  9 | from .logger import Logger, LogLevel
 10 | import json
 11 | import logging
 12 | from core.telemetry import is_telemetry_enabled, record_event
 13 | import os
 14 | from . import helpers
 15 | 
 16 | import platform
 17 | 
 18 | SYSTEM_INFO = {
 19 |     "os": platform.system().lower(),
 20 |     "os_version": platform.release(),
 21 |     "python_version": platform.python_version(),
 22 | }
 23 | 
 24 | # Import provider related modules
 25 | from .providers.base import VMProviderType
 26 | from .providers.factory import VMProviderFactory
 27 | 
 28 | OSType = Literal["macos", "linux", "windows"]
 29 | 
 30 | class Computer:
 31 |     """Computer is the main class for interacting with the computer."""
 32 | 
 33 |     def create_desktop_from_apps(self, apps):
 34 |         """
 35 |         Create a virtual desktop from a list of app names, returning a DioramaComputer
 36 |         that proxies Diorama.Interface but uses diorama_cmds via the computer interface.
 37 | 
 38 |         Args:
 39 |             apps (list[str]): List of application names to include in the desktop.
 40 |         Returns:
 41 |             DioramaComputer: A proxy object with the Diorama interface, but using diorama_cmds.
 42 |         """
 43 |         assert "app-use" in self.experiments, "App Usage is an experimental feature. Enable it by passing experiments=['app-use'] to Computer()"
 44 |         from .diorama_computer import DioramaComputer
 45 |         return DioramaComputer(self, apps)
 46 | 
 47 |     def __init__(
 48 |         self,
 49 |         display: Union[Display, Dict[str, int], str] = "1024x768",
 50 |         memory: str = "8GB",
 51 |         cpu: str = "4",
 52 |         os_type: OSType = "macos",
 53 |         name: str = "",
 54 |         image: Optional[str] = None,
 55 |         shared_directories: Optional[List[str]] = None,
 56 |         use_host_computer_server: bool = False,
 57 |         verbosity: Union[int, LogLevel] = logging.INFO,
 58 |         telemetry_enabled: bool = True,
 59 |         provider_type: Union[str, VMProviderType] = VMProviderType.LUME,
 60 |         port: Optional[int] = 7777,
 61 |         noVNC_port: Optional[int] = 8006,
 62 |         host: str = os.environ.get("PYLUME_HOST", "localhost"),
 63 |         storage: Optional[str] = None,
 64 |         ephemeral: bool = False,
 65 |         api_key: Optional[str] = None,
 66 |         experiments: Optional[List[str]] = None
 67 |     ):
 68 |         """Initialize a new Computer instance.
 69 | 
 70 |         Args:
 71 |             display: The display configuration. Can be:
 72 |                     - A Display object
 73 |                     - A dict with 'width' and 'height'
 74 |                     - A string in format "WIDTHxHEIGHT" (e.g. "1920x1080")
 75 |                     Defaults to "1024x768"
 76 |             memory: The VM memory allocation. Defaults to "8GB"
 77 |             cpu: The VM CPU allocation. Defaults to "4"
 78 |             os_type: The operating system type ('macos' or 'linux')
 79 |             name: The VM name
 80 |             image: The VM image name
 81 |             shared_directories: Optional list of directory paths to share with the VM
 82 |             use_host_computer_server: If True, target localhost instead of starting a VM
 83 |             verbosity: Logging level (standard Python logging levels: logging.DEBUG, logging.INFO, etc.)
 84 |                       LogLevel enum values are still accepted for backward compatibility
 85 |             telemetry_enabled: Whether to enable telemetry tracking. Defaults to True.
 86 |             provider_type: The VM provider type to use (lume, qemu, cloud)
 87 |             port: Optional port to use for the VM provider server
 88 |             noVNC_port: Optional port for the noVNC web interface (Lumier provider)
 89 |             host: Host to use for VM provider connections (e.g. "localhost", "host.docker.internal")
 90 |             storage: Optional path for persistent VM storage (Lumier provider)
 91 |             ephemeral: Whether to use ephemeral storage
 92 |             api_key: Optional API key for cloud providers
 93 |             experiments: Optional list of experimental features to enable (e.g. ["app-use"])
 94 |         """
 95 | 
 96 |         self.logger = Logger("computer", verbosity)
 97 |         self.logger.info("Initializing Computer...")
 98 | 
 99 |         if not image:
100 |             if os_type == "macos":
101 |                 image = "macos-sequoia-cua:latest"
102 |             elif os_type == "linux":
103 |                 image = "trycua/cua-ubuntu:latest"
104 |         image = str(image)
105 | 
106 |         # Store original parameters
107 |         self.image = image
108 |         self.port = port
109 |         self.noVNC_port = noVNC_port
110 |         self.host = host
111 |         self.os_type = os_type
112 |         self.provider_type = provider_type
113 |         self.ephemeral = ephemeral
114 |         
115 |         self.api_key = api_key
116 |         self.experiments = experiments or []
117 |         
118 |         if "app-use" in self.experiments:
119 |             assert self.os_type == "macos", "App use experiment is only supported on macOS"
120 | 
121 |         # The default is currently to use non-ephemeral storage
122 |         if storage and ephemeral and storage != "ephemeral":
123 |             raise ValueError("Storage path and ephemeral flag cannot be used together")
124 |         
125 |         # Windows Sandbox always uses ephemeral storage
126 |         if self.provider_type == VMProviderType.WINSANDBOX:
127 |             if not ephemeral and storage != None and storage != "ephemeral":
128 |                 self.logger.warning("Windows Sandbox storage is always ephemeral. Setting ephemeral=True.")
129 |             self.ephemeral = True
130 |             self.storage = "ephemeral"
131 |         else:
132 |             self.storage = "ephemeral" if ephemeral else storage
133 |         
134 |         # For Lumier provider, store the first shared directory path to use
135 |         # for VM file sharing
136 |         self.shared_path = None
137 |         if shared_directories and len(shared_directories) > 0:
138 |             self.shared_path = shared_directories[0]
139 |             self.logger.info(f"Using first shared directory for VM file sharing: {self.shared_path}")
140 | 
141 |         # Store telemetry preference
142 |         self._telemetry_enabled = telemetry_enabled
143 | 
144 |         # Set initialization flag
145 |         self._initialized = False
146 |         self._running = False
147 | 
148 |         # Configure root logger
149 |         self.verbosity = verbosity
150 |         self.logger = Logger("computer", verbosity)
151 | 
152 |         # Configure component loggers with proper hierarchy
153 |         self.vm_logger = Logger("computer.vm", verbosity)
154 |         self.interface_logger = Logger("computer.interface", verbosity)
155 | 
156 |         if not use_host_computer_server:
157 |             if ":" not in image:
158 |                 image = f"{image}:latest"
159 | 
160 |             if not name:
161 |                 # Normalize the name to be used for the VM
162 |                 name = image.replace(":", "_")
163 |                 # Remove any forward slashes
164 |                 name = name.replace("/", "_")
165 | 
166 |             # Convert display parameter to Display object
167 |             if isinstance(display, str):
168 |                 # Parse string format "WIDTHxHEIGHT"
169 |                 match = re.match(r"(\d+)x(\d+)", display)
170 |                 if not match:
171 |                     raise ValueError(
172 |                         "Display string must be in format 'WIDTHxHEIGHT' (e.g. '1024x768')"
173 |                     )
174 |                 width, height = map(int, match.groups())
175 |                 display_config = Display(width=width, height=height)
176 |             elif isinstance(display, dict):
177 |                 display_config = Display(**display)
178 |             else:
179 |                 display_config = display
180 | 
181 |             self.config = ComputerConfig(
182 |                 image=image.split(":")[0],
183 |                 tag=image.split(":")[1],
184 |                 name=name,
185 |                 display=display_config,
186 |                 memory=memory,
187 |                 cpu=cpu,
188 |             )
189 |             # Initialize VM provider but don't start it yet - we'll do that in run()
190 |             self.config.vm_provider = None  # Will be initialized in run()
191 | 
192 |         # Store shared directories config
193 |         self.shared_directories = shared_directories or []
194 | 
195 |         # Placeholder for VM provider context manager
196 |         self._provider_context = None
197 | 
198 |         # Initialize with proper typing - None at first, will be set in run()
199 |         self._interface = None
200 |         self.use_host_computer_server = use_host_computer_server
201 | 
202 |         # Record initialization in telemetry (if enabled)
203 |         if telemetry_enabled and is_telemetry_enabled():
204 |             record_event("computer_initialized", SYSTEM_INFO)
205 |         else:
206 |             self.logger.debug("Telemetry disabled - skipping initialization tracking")
207 | 
208 |     async def __aenter__(self):
209 |         """Start the computer."""
210 |         await self.run()
211 |         return self
212 | 
213 |     async def __aexit__(self, exc_type, exc_val, exc_tb):
214 |         """Stop the computer."""
215 |         await self.disconnect()
216 | 
217 |     def __enter__(self):
218 |         """Start the computer."""
219 |         # Run the event loop to call the async enter method
220 |         loop = asyncio.get_event_loop()
221 |         loop.run_until_complete(self.__aenter__())
222 |         return self
223 | 
224 |     def __exit__(self, exc_type, exc_val, exc_tb):
225 |         """Stop the computer."""
226 |         loop = asyncio.get_event_loop()
227 |         loop.run_until_complete(self.__aexit__(exc_type, exc_val, exc_tb))
228 | 
229 |     async def run(self) -> Optional[str]:
230 |         """Initialize the VM and computer interface."""
231 |         if TYPE_CHECKING:
232 |             from .interface.base import BaseComputerInterface
233 | 
234 |         # If already initialized, just log and return
235 |         if hasattr(self, "_initialized") and self._initialized:
236 |             self.logger.info("Computer already initialized, skipping initialization")
237 |             return
238 | 
239 |         self.logger.info("Starting computer...")
240 |         start_time = time.time()
241 | 
242 |         try:
243 |             # If using host computer server
244 |             if self.use_host_computer_server:
245 |                 self.logger.info("Using host computer server")
246 |                 # Set ip_address for host computer server mode
247 |                 ip_address = "localhost"
248 |                 # Create the interface with explicit type annotation
249 |                 from .interface.base import BaseComputerInterface
250 | 
251 |                 self._interface = cast(
252 |                     BaseComputerInterface,
253 |                     InterfaceFactory.create_interface_for_os(
254 |                         os=self.os_type, ip_address=ip_address  # type: ignore[arg-type]
255 |                     ),
256 |                 )
257 | 
258 |                 self.logger.info("Waiting for host computer server to be ready...")
259 |                 await self._interface.wait_for_ready()
260 |                 self.logger.info("Host computer server ready")
261 |             else:
262 |                 # Start or connect to VM
263 |                 self.logger.info(f"Starting VM: {self.image}")
264 |                 if not self._provider_context:
265 |                     try:
266 |                         provider_type_name = self.provider_type.name if isinstance(self.provider_type, VMProviderType) else self.provider_type
267 |                         self.logger.verbose(f"Initializing {provider_type_name} provider context...")
268 | 
269 |                         # Explicitly set provider parameters
270 |                         storage = "ephemeral" if self.ephemeral else self.storage
271 |                         verbose = self.verbosity >= LogLevel.DEBUG
272 |                         ephemeral = self.ephemeral
273 |                         port = self.port if self.port is not None else 7777
274 |                         host = self.host if self.host else "localhost"
275 |                         image = self.image
276 |                         shared_path = self.shared_path
277 |                         noVNC_port = self.noVNC_port
278 | 
279 |                         # Create VM provider instance with explicit parameters
280 |                         try:
281 |                             if self.provider_type == VMProviderType.LUMIER:
282 |                                 self.logger.info(f"Using VM image for Lumier provider: {image}")
283 |                                 if shared_path:
284 |                                     self.logger.info(f"Using shared path for Lumier provider: {shared_path}")
285 |                                 if noVNC_port:
286 |                                     self.logger.info(f"Using noVNC port for Lumier provider: {noVNC_port}")
287 |                                 self.config.vm_provider = VMProviderFactory.create_provider(
288 |                                     self.provider_type,
289 |                                     port=port,
290 |                                     host=host,
291 |                                     storage=storage,
292 |                                     shared_path=shared_path,
293 |                                     image=image,
294 |                                     verbose=verbose,
295 |                                     ephemeral=ephemeral,
296 |                                     noVNC_port=noVNC_port,
297 |                                 )
298 |                             elif self.provider_type == VMProviderType.LUME:
299 |                                 self.config.vm_provider = VMProviderFactory.create_provider(
300 |                                     self.provider_type,
301 |                                     port=port,
302 |                                     host=host,
303 |                                     storage=storage,
304 |                                     verbose=verbose,
305 |                                     ephemeral=ephemeral,
306 |                                 )
307 |                             elif self.provider_type == VMProviderType.CLOUD:
308 |                                 self.config.vm_provider = VMProviderFactory.create_provider(
309 |                                     self.provider_type,
310 |                                     api_key=self.api_key,
311 |                                     verbose=verbose,
312 |                                 )
313 |                             elif self.provider_type == VMProviderType.WINSANDBOX:
314 |                                 self.config.vm_provider = VMProviderFactory.create_provider(
315 |                                     self.provider_type,
316 |                                     port=port,
317 |                                     host=host,
318 |                                     storage=storage,
319 |                                     verbose=verbose,
320 |                                     ephemeral=ephemeral,
321 |                                     noVNC_port=noVNC_port,
322 |                                 )
323 |                             elif self.provider_type == VMProviderType.DOCKER:
324 |                                 self.config.vm_provider = VMProviderFactory.create_provider(
325 |                                     self.provider_type,
326 |                                     port=port,
327 |                                     host=host,
328 |                                     storage=storage,
329 |                                     shared_path=shared_path,
330 |                                     image=image or "trycua/cua-ubuntu:latest",
331 |                                     verbose=verbose,
332 |                                     ephemeral=ephemeral,
333 |                                     noVNC_port=noVNC_port,
334 |                                 )
335 |                             else:
336 |                                 raise ValueError(f"Unsupported provider type: {self.provider_type}")
337 |                             self._provider_context = await self.config.vm_provider.__aenter__()
338 |                             self.logger.verbose("VM provider context initialized successfully")
339 |                         except ImportError as ie:
340 |                             self.logger.error(f"Failed to import provider dependencies: {ie}")
341 |                             if str(ie).find("lume") >= 0 and str(ie).find("lumier") < 0:
342 |                                 self.logger.error("Please install with: pip install cua-computer[lume]")
343 |                             elif str(ie).find("lumier") >= 0 or str(ie).find("docker") >= 0:
344 |                                 self.logger.error("Please install with: pip install cua-computer[lumier] and make sure Docker is installed")
345 |                             elif str(ie).find("cloud") >= 0:
346 |                                 self.logger.error("Please install with: pip install cua-computer[cloud]")
347 |                             raise
348 |                     except Exception as e:
349 |                         self.logger.error(f"Failed to initialize provider context: {e}")
350 |                         raise RuntimeError(f"Failed to initialize VM provider: {e}")
351 | 
352 |                 # Check if VM exists or create it
353 |                 is_running = False
354 |                 try:
355 |                     if self.config.vm_provider is None:
356 |                         raise RuntimeError(f"VM provider not initialized for {self.config.name}")
357 |                         
358 |                     vm = await self.config.vm_provider.get_vm(self.config.name)
359 |                     self.logger.verbose(f"Found existing VM: {self.config.name}")
360 |                     is_running = vm.get("status") == "running"
361 |                 except Exception as e:
362 |                     self.logger.error(f"VM not found: {self.config.name}")
363 |                     self.logger.error(f"Error: {e}")
364 |                     raise RuntimeError(
365 |                         f"VM {self.config.name} could not be found or created."
366 |                     )
367 | 
368 |                 # Start the VM if it's not running
369 |                 if not is_running:
370 |                     self.logger.info(f"VM {self.config.name} is not running, starting it...")
371 | 
372 |                     # Convert paths to dictionary format for shared directories
373 |                     shared_dirs = []
374 |                     for path in self.shared_directories:
375 |                         self.logger.verbose(f"Adding shared directory: {path}")
376 |                         path = os.path.abspath(os.path.expanduser(path))
377 |                         if os.path.exists(path):
378 |                             # Add path in format expected by Lume API
379 |                             shared_dirs.append({
380 |                                 "hostPath": path,
381 |                                 "readOnly": False
382 |                             })
383 |                         else:
384 |                             self.logger.warning(f"Shared directory does not exist: {path}")
385 |                             
386 |                     # Prepare run options to pass to the provider
387 |                     run_opts = {}
388 | 
389 |                     # Add display information if available
390 |                     if self.config.display is not None:
391 |                         display_info = {
392 |                             "width": self.config.display.width,
393 |                             "height": self.config.display.height,
394 |                         }
395 |                         
396 |                         # Check if scale_factor exists before adding it
397 |                         if hasattr(self.config.display, "scale_factor"):
398 |                             display_info["scale_factor"] = self.config.display.scale_factor
399 |                         
400 |                         run_opts["display"] = display_info
401 | 
402 |                     # Add shared directories if available
403 |                     if self.shared_directories:
404 |                         run_opts["shared_directories"] = shared_dirs.copy()
405 | 
406 |                     # Run the VM with the provider
407 |                     try:
408 |                         if self.config.vm_provider is None:
409 |                             raise RuntimeError(f"VM provider not initialized for {self.config.name}")
410 |                             
411 |                         # Use the complete run_opts we prepared earlier
412 |                         # Handle ephemeral storage for run_vm method too
413 |                         storage_param = "ephemeral" if self.ephemeral else self.storage
414 |                         
415 |                         # Log the image being used
416 |                         self.logger.info(f"Running VM using image: {self.image}")
417 |                         
418 |                         # Call provider.run_vm with explicit image parameter
419 |                         response = await self.config.vm_provider.run_vm(
420 |                             image=self.image,
421 |                             name=self.config.name,
422 |                             run_opts=run_opts,
423 |                             storage=storage_param
424 |                         )
425 |                         self.logger.info(f"VM run response: {response if response else 'None'}")
426 |                     except Exception as run_error:
427 |                         self.logger.error(f"Failed to run VM: {run_error}")
428 |                         raise RuntimeError(f"Failed to start VM: {run_error}")
429 | 
430 |                 # Wait for VM to be ready with a valid IP address
431 |                 self.logger.info("Waiting for VM to be ready with a valid IP address...")
432 |                 try:
433 |                     if self.provider_type == VMProviderType.LUMIER:
434 |                         max_retries = 60  # Increased for Lumier VM startup which takes longer
435 |                         retry_delay = 3    # 3 seconds between retries for Lumier
436 |                     else:
437 |                         max_retries = 30  # Default for other providers
438 |                         retry_delay = 2    # 2 seconds between retries
439 |                     
440 |                     self.logger.info(f"Waiting up to {max_retries * retry_delay} seconds for VM to be ready...")
441 |                     ip = await self.get_ip(max_retries=max_retries, retry_delay=retry_delay)
442 |                     
443 |                     # If we get here, we have a valid IP
444 |                     self.logger.info(f"VM is ready with IP: {ip}")
445 |                     ip_address = ip
446 |                 except TimeoutError as timeout_error:
447 |                     self.logger.error(str(timeout_error))
448 |                     raise RuntimeError(f"VM startup timed out: {timeout_error}")
449 |                 except Exception as wait_error:
450 |                     self.logger.error(f"Error waiting for VM: {wait_error}")
451 |                     raise RuntimeError(f"VM failed to become ready: {wait_error}")
452 |         except Exception as e:
453 |             self.logger.error(f"Failed to initialize computer: {e}")
454 |             raise RuntimeError(f"Failed to initialize computer: {e}")
455 | 
456 |         try:
457 |             # Verify we have a valid IP before initializing the interface
458 |             if not ip_address or ip_address == "unknown" or ip_address == "0.0.0.0":
459 |                 raise RuntimeError(f"Cannot initialize interface - invalid IP address: {ip_address}")
460 |                 
461 |             # Initialize the interface using the factory with the specified OS
462 |             self.logger.info(f"Initializing interface for {self.os_type} at {ip_address}")
463 |             from .interface.base import BaseComputerInterface
464 | 
465 |             # Pass authentication credentials if using cloud provider
466 |             if self.provider_type == VMProviderType.CLOUD and self.api_key and self.config.name:
467 |                 self._interface = cast(
468 |                     BaseComputerInterface,
469 |                     InterfaceFactory.create_interface_for_os(
470 |                         os=self.os_type, 
471 |                         ip_address=ip_address,
472 |                         api_key=self.api_key,
473 |                         vm_name=self.config.name
474 |                     ),
475 |                 )
476 |             else:
477 |                 self._interface = cast(
478 |                     BaseComputerInterface,
479 |                     InterfaceFactory.create_interface_for_os(
480 |                         os=self.os_type, 
481 |                         ip_address=ip_address
482 |                     ),
483 |                 )
484 | 
485 |             # Wait for the WebSocket interface to be ready
486 |             self.logger.info("Connecting to WebSocket interface...")
487 | 
488 |             try:
489 |                 # Use a single timeout for the entire connection process
490 |                 # The VM should already be ready at this point, so we're just establishing the connection
491 |                 await self._interface.wait_for_ready(timeout=30)
492 |                 self.logger.info("WebSocket interface connected successfully")
493 |             except TimeoutError as e:
494 |                 self.logger.error(f"Failed to connect to WebSocket interface at {ip_address}")
495 |                 raise TimeoutError(
496 |                     f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}"
497 |                 )
498 |                 # self.logger.warning(
499 |                 #     f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}, expect missing functionality"
500 |                 # )
501 | 
502 |             # Create an event to keep the VM running in background if needed
503 |             if not self.use_host_computer_server:
504 |                 self._stop_event = asyncio.Event()
505 |                 self._keep_alive_task = asyncio.create_task(self._stop_event.wait())
506 | 
507 |             self.logger.info("Computer is ready")
508 | 
509 |             # Set the initialization flag and clear the initializing flag
510 |             self._initialized = True
511 |             
512 |             # Set this instance as the default computer for remote decorators
513 |             helpers.set_default_computer(self)
514 |             
515 |             self.logger.info("Computer successfully initialized")
516 |         except Exception as e:
517 |             raise
518 |         finally:
519 |             # Log initialization time for performance monitoring
520 |             duration_ms = (time.time() - start_time) * 1000
521 |             self.logger.debug(f"Computer initialization took {duration_ms:.2f}ms")
522 |         return
523 |     
524 |     async def disconnect(self) -> None:
525 |         """Disconnect from the computer's WebSocket interface."""
526 |         if self._interface:
527 |             self._interface.close()
528 | 
529 |     async def stop(self) -> None:
530 |         """Disconnect from the computer's WebSocket interface and stop the computer."""
531 |         start_time = time.time()
532 | 
533 |         try:
534 |             self.logger.info("Stopping Computer...")
535 | 
536 |             # In VM mode, first explicitly stop the VM, then exit the provider context
537 |             if not self.use_host_computer_server and self._provider_context and self.config.vm_provider is not None:
538 |                 try:
539 |                     self.logger.info(f"Stopping VM {self.config.name}...")
540 |                     await self.config.vm_provider.stop_vm(
541 |                     name=self.config.name,
542 |                     storage=self.storage  # Pass storage explicitly for clarity
543 |                 )
544 |                 except Exception as e:
545 |                     self.logger.error(f"Error stopping VM: {e}")
546 | 
547 |                 self.logger.verbose("Closing VM provider context...")
548 |                 await self.config.vm_provider.__aexit__(None, None, None)
549 |                 self._provider_context = None
550 | 
551 |             await self.disconnect()
552 |             self.logger.info("Computer stopped")
553 |         except Exception as e:
554 |             self.logger.debug(f"Error during cleanup: {e}")  # Log as debug since this might be expected
555 |         finally:
556 |             # Log stop time for performance monitoring
557 |             duration_ms = (time.time() - start_time) * 1000
558 |             self.logger.debug(f"Computer stop process took {duration_ms:.2f}ms")
559 |         return
560 | 
561 |     # @property
562 |     async def get_ip(self, max_retries: int = 15, retry_delay: int = 3) -> str:
563 |         """Get the IP address of the VM or localhost if using host computer server.
564 |         
565 |         This method delegates to the provider's get_ip method, which waits indefinitely 
566 |         until the VM has a valid IP address.
567 |         
568 |         Args:
569 |             max_retries: Unused parameter, kept for backward compatibility
570 |             retry_delay: Delay between retries in seconds (default: 2)
571 |             
572 |         Returns:
573 |             IP address of the VM or localhost if using host computer server
574 |         """
575 |         # For host computer server, always return localhost immediately
576 |         if self.use_host_computer_server:
577 |             return "127.0.0.1"
578 |             
579 |         # Get IP from the provider - each provider implements its own waiting logic
580 |         if self.config.vm_provider is None:
581 |             raise RuntimeError("VM provider is not initialized")
582 |         
583 |         # Log that we're waiting for the IP
584 |         self.logger.info(f"Waiting for VM {self.config.name} to get an IP address...")
585 |         
586 |         # Call the provider's get_ip method which will wait indefinitely
587 |         storage_param = "ephemeral" if self.ephemeral else self.storage
588 |         
589 |         # Log the image being used
590 |         self.logger.info(f"Running VM using image: {self.image}")
591 |         
592 |         # Call provider.get_ip with explicit image parameter
593 |         ip = await self.config.vm_provider.get_ip(
594 |             name=self.config.name,
595 |             storage=storage_param,
596 |             retry_delay=retry_delay
597 |         )
598 |         
599 |         # Log success
600 |         self.logger.info(f"VM {self.config.name} has IP address: {ip}")
601 |         return ip
602 |         
603 | 
604 |     async def wait_vm_ready(self) -> Optional[Dict[str, Any]]:
605 |         """Wait for VM to be ready with an IP address.
606 | 
607 |         Returns:
608 |             VM status information or None if using host computer server.
609 |         """
610 |         if self.use_host_computer_server:
611 |             return None
612 | 
613 |         timeout = 600  # 10 minutes timeout (increased from 4 minutes)
614 |         interval = 2.0  # 2 seconds between checks (increased to reduce API load)
615 |         start_time = time.time()
616 |         last_status = None
617 |         attempts = 0
618 | 
619 |         self.logger.info(f"Waiting for VM {self.config.name} to be ready (timeout: {timeout}s)...")
620 | 
621 |         while time.time() - start_time < timeout:
622 |             attempts += 1
623 |             elapsed = time.time() - start_time
624 | 
625 |             try:
626 |                 # Keep polling for VM info
627 |                 if self.config.vm_provider is None:
628 |                     self.logger.error("VM provider is not initialized")
629 |                     vm = None
630 |                 else:
631 |                     vm = await self.config.vm_provider.get_vm(self.config.name)
632 | 
633 |                 # Log full VM properties for debugging (every 30 attempts)
634 |                 if attempts % 30 == 0:
635 |                     self.logger.info(
636 |                         f"VM properties at attempt {attempts}: {vars(vm) if vm else 'None'}"
637 |                     )
638 | 
639 |                 # Get current status for logging
640 |                 current_status = getattr(vm, "status", None) if vm else None
641 |                 if current_status != last_status:
642 |                     self.logger.info(
643 |                         f"VM status changed to: {current_status} (after {elapsed:.1f}s)"
644 |                     )
645 |                     last_status = current_status
646 | 
647 |                 # Check for IP address - ensure it's not None or empty
648 |                 ip = getattr(vm, "ip_address", None) if vm else None
649 |                 if ip and ip.strip():  # Check for non-empty string
650 |                     self.logger.info(
651 |                         f"VM {self.config.name} got IP address: {ip} (after {elapsed:.1f}s)"
652 |                     )
653 |                     return vm
654 | 
655 |                 if attempts % 10 == 0:  # Log every 10 attempts to avoid flooding
656 |                     self.logger.info(
657 |                         f"Still waiting for VM IP address... (elapsed: {elapsed:.1f}s)"
658 |                     )
659 |                 else:
660 |                     self.logger.debug(
661 |                         f"Waiting for VM IP address... Current IP: {ip}, Status: {current_status}"
662 |                     )
663 | 
664 |             except Exception as e:
665 |                 self.logger.warning(f"Error checking VM status (attempt {attempts}): {str(e)}")
666 |                 # If we've been trying for a while and still getting errors, log more details
667 |                 if elapsed > 60:  # After 1 minute of errors, log more details
668 |                     self.logger.error(f"Persistent error getting VM status: {str(e)}")
669 |                     self.logger.info("Trying to get VM list for debugging...")
670 |                     try:
671 |                         if self.config.vm_provider is not None:
672 |                             vms = await self.config.vm_provider.list_vms()
673 |                             self.logger.info(
674 |                                 f"Available VMs: {[getattr(vm, 'name', None) for vm in vms if hasattr(vm, 'name')]}"
675 |                             )
676 |                     except Exception as list_error:
677 |                         self.logger.error(f"Failed to list VMs: {str(list_error)}")
678 | 
679 |             await asyncio.sleep(interval)
680 | 
681 |         # If we get here, we've timed out
682 |         elapsed = time.time() - start_time
683 |         self.logger.error(f"VM {self.config.name} not ready after {elapsed:.1f} seconds")
684 | 
685 |         # Try to get final VM status for debugging
686 |         try:
687 |             if self.config.vm_provider is not None:
688 |                 vm = await self.config.vm_provider.get_vm(self.config.name)
689 |                 # VM data is returned as a dictionary from the Lumier provider
690 |                 status = vm.get('status', 'unknown') if vm else "unknown"
691 |                 ip = vm.get('ip_address') if vm else None
692 |             else:
693 |                 status = "unknown"
694 |                 ip = None
695 |             self.logger.error(f"Final VM status: {status}, IP: {ip}")
696 |         except Exception as e:
697 |             self.logger.error(f"Failed to get final VM status: {str(e)}")
698 | 
699 |         raise TimeoutError(
700 |             f"VM {self.config.name} not ready after {elapsed:.1f} seconds - IP address not assigned"
701 |         )
702 | 
703 |     async def update(self, cpu: Optional[int] = None, memory: Optional[str] = None):
704 |         """Update VM settings."""
705 |         self.logger.info(
706 |             f"Updating VM settings: CPU={cpu or self.config.cpu}, Memory={memory or self.config.memory}"
707 |         )
708 |         update_opts = {
709 |             "cpu": cpu or int(self.config.cpu), 
710 |             "memory": memory or self.config.memory
711 |         }
712 |         if self.config.vm_provider is not None:
713 |                 await self.config.vm_provider.update_vm(
714 |                     name=self.config.name,
715 |                     update_opts=update_opts,
716 |                     storage=self.storage  # Pass storage explicitly for clarity
717 |                 )
718 |         else:
719 |             raise RuntimeError("VM provider not initialized")
720 | 
721 |     def get_screenshot_size(self, screenshot: bytes) -> Dict[str, int]:
722 |         """Get the dimensions of a screenshot.
723 | 
724 |         Args:
725 |             screenshot: The screenshot bytes
726 | 
727 |         Returns:
728 |             Dict[str, int]: Dictionary containing 'width' and 'height' of the image
729 |         """
730 |         image = Image.open(io.BytesIO(screenshot))
731 |         width, height = image.size
732 |         return {"width": width, "height": height}
733 | 
734 |     @property
735 |     def interface(self):
736 |         """Get the computer interface for interacting with the VM.
737 | 
738 |         Returns:
739 |             The computer interface
740 |         """
741 |         if not hasattr(self, "_interface") or self._interface is None:
742 |             error_msg = "Computer interface not initialized. Call run() first."
743 |             self.logger.error(error_msg)
744 |             self.logger.error(
745 |                 "Make sure to call await computer.run() before using any interface methods."
746 |             )
747 |             raise RuntimeError(error_msg)
748 | 
749 |         return self._interface
750 | 
751 |     @property
752 |     def telemetry_enabled(self) -> bool:
753 |         """Check if telemetry is enabled for this computer instance.
754 | 
755 |         Returns:
756 |             bool: True if telemetry is enabled, False otherwise
757 |         """
758 |         return self._telemetry_enabled
759 | 
760 |     async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
761 |         """Convert normalized coordinates to screen coordinates.
762 | 
763 |         Args:
764 |             x: X coordinate between 0 and 1
765 |             y: Y coordinate between 0 and 1
766 | 
767 |         Returns:
768 |             tuple[float, float]: Screen coordinates (x, y)
769 |         """
770 |         return await self.interface.to_screen_coordinates(x, y)
771 | 
772 |     async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]:
773 |         """Convert screen coordinates to screenshot coordinates.
774 | 
775 |         Args:
776 |             x: X coordinate in screen space
777 |             y: Y coordinate in screen space
778 | 
779 |         Returns:
780 |             tuple[float, float]: (x, y) coordinates in screenshot space
781 |         """
782 |         return await self.interface.to_screenshot_coordinates(x, y)
783 | 
784 | 
785 |     # Add virtual environment management functions to computer interface
786 |     async def venv_install(self, venv_name: str, requirements: list[str]):
787 |         """Install packages in a virtual environment.
788 |         
789 |         Args:
790 |             venv_name: Name of the virtual environment
791 |             requirements: List of package requirements to install
792 |             
793 |         Returns:
794 |             Tuple of (stdout, stderr) from the installation command
795 |         """
796 |         requirements = requirements or []
797 |         # Windows vs POSIX handling
798 |         if self.os_type == "windows":
799 |             # Use %USERPROFILE% for home directory and cmd.exe semantics
800 |             venv_path = f"%USERPROFILE%\\.venvs\\{venv_name}"
801 |             ensure_dir_cmd = "if not exist \"%USERPROFILE%\\.venvs\" mkdir \"%USERPROFILE%\\.venvs\""
802 |             create_cmd = f"if not exist \"{venv_path}\" python -m venv \"{venv_path}\""
803 |             requirements_str = " ".join(requirements)
804 |             # Activate via activate.bat and install
805 |             install_cmd = f"call \"{venv_path}\\Scripts\\activate.bat\" && pip install {requirements_str}" if requirements_str else f"echo No requirements to install"
806 |             await self.interface.run_command(ensure_dir_cmd)
807 |             await self.interface.run_command(create_cmd)
808 |             return await self.interface.run_command(install_cmd)
809 |         else:
810 |             # POSIX (macOS/Linux)
811 |             venv_path = f"$HOME/.venvs/{venv_name}"
812 |             create_cmd = f"mkdir -p \"$HOME/.venvs\" && python3 -m venv \"{venv_path}\""
813 |             # Check if venv exists, if not create it
814 |             check_cmd = f"test -d \"{venv_path}\" || ({create_cmd})"
815 |             _ = await self.interface.run_command(check_cmd)
816 |             # Install packages
817 |             requirements_str = " ".join(requirements)
818 |             install_cmd = (
819 |                 f". \"{venv_path}/bin/activate\" && pip install {requirements_str}"
820 |                 if requirements_str
821 |                 else "echo No requirements to install"
822 |             )
823 |             return await self.interface.run_command(install_cmd)
824 |     
825 |     async def venv_cmd(self, venv_name: str, command: str):
826 |         """Execute a shell command in a virtual environment.
827 |         
828 |         Args:
829 |             venv_name: Name of the virtual environment
830 |             command: Shell command to execute in the virtual environment
831 |             
832 |         Returns:
833 |             Tuple of (stdout, stderr) from the command execution
834 |         """
835 |         if self.os_type == "windows":
836 |             # Windows (cmd.exe)
837 |             venv_path = f"%USERPROFILE%\\.venvs\\{venv_name}"
838 |             # Check existence and signal if missing
839 |             check_cmd = f"if not exist \"{venv_path}\" (echo VENV_NOT_FOUND) else (echo VENV_FOUND)"
840 |             result = await self.interface.run_command(check_cmd)
841 |             if "VENV_NOT_FOUND" in getattr(result, "stdout", ""):
842 |                 # Auto-create the venv with no requirements
843 |                 await self.venv_install(venv_name, [])
844 |             # Activate and run the command
845 |             full_command = f"call \"{venv_path}\\Scripts\\activate.bat\" && {command}"
846 |             return await self.interface.run_command(full_command)
847 |         else:
848 |             # POSIX (macOS/Linux)
849 |             venv_path = f"$HOME/.venvs/{venv_name}"
850 |             # Check if virtual environment exists
851 |             check_cmd = f"test -d \"{venv_path}\""
852 |             result = await self.interface.run_command(check_cmd)
853 |             if result.stderr or "test:" in result.stdout:  # venv doesn't exist
854 |                 # Auto-create the venv with no requirements
855 |                 await self.venv_install(venv_name, [])
856 |             # Activate virtual environment and run command
857 |             full_command = f". \"{venv_path}/bin/activate\" && {command}"
858 |             return await self.interface.run_command(full_command)
859 |     
860 |     async def venv_exec(self, venv_name: str, python_func, *args, **kwargs):
861 |         """Execute Python function in a virtual environment using source code extraction.
862 |         
863 |         Args:
864 |             venv_name: Name of the virtual environment
865 |             python_func: A callable function to execute
866 |             *args: Positional arguments to pass to the function
867 |             **kwargs: Keyword arguments to pass to the function
868 |             
869 |         Returns:
870 |             The result of the function execution, or raises any exception that occurred
871 |         """
872 |         import base64
873 |         import inspect
874 |         import json
875 |         import textwrap
876 |         
877 |         try:
878 |             # Get function source code using inspect.getsource
879 |             source = inspect.getsource(python_func)
880 |             # Remove common leading whitespace (dedent)
881 |             func_source = textwrap.dedent(source).strip()
882 |             
883 |             # Remove decorators
884 |             while func_source.lstrip().startswith("@"):
885 |                 func_source = func_source.split("\n", 1)[1].strip()
886 |             
887 |             # Get function name for execution
888 |             func_name = python_func.__name__
889 |             
890 |             # Serialize args and kwargs as JSON (safer than dill for cross-version compatibility)
891 |             args_json = json.dumps(args, default=str)
892 |             kwargs_json = json.dumps(kwargs, default=str)
893 |             
894 |         except OSError as e:
895 |             raise Exception(f"Cannot retrieve source code for function {python_func.__name__}: {e}")
896 |         except Exception as e:
897 |             raise Exception(f"Failed to reconstruct function source: {e}")
898 |         
899 |         # Create Python code that will define and execute the function
900 |         python_code = f'''
901 | import json
902 | import traceback
903 | 
904 | try:
905 |     # Define the function from source
906 | {textwrap.indent(func_source, "    ")}
907 |     
908 |     # Deserialize args and kwargs from JSON
909 |     args_json = """{args_json}"""
910 |     kwargs_json = """{kwargs_json}"""
911 |     args = json.loads(args_json)
912 |     kwargs = json.loads(kwargs_json)
913 |     
914 |     # Execute the function
915 |     result = {func_name}(*args, **kwargs)
916 | 
917 |     # Create success output payload
918 |     output_payload = {{
919 |         "success": True,
920 |         "result": result,
921 |         "error": None
922 |     }}
923 |     
924 | except Exception as e:
925 |     # Create error output payload
926 |     output_payload = {{
927 |         "success": False,
928 |         "result": None,
929 |         "error": {{
930 |             "type": type(e).__name__,
931 |             "message": str(e),
932 |             "traceback": traceback.format_exc()
933 |         }}
934 |     }}
935 | 
936 | # Serialize the output payload as JSON
937 | import json
938 | output_json = json.dumps(output_payload, default=str)
939 | 
940 | # Print the JSON output with markers
941 | print(f"<<<VENV_EXEC_START>>>{{output_json}}<<<VENV_EXEC_END>>>")
942 | '''
943 |         
944 |         # Encode the Python code in base64 to avoid shell escaping issues
945 |         encoded_code = base64.b64encode(python_code.encode('utf-8')).decode('ascii')
946 |         
947 |         # Execute the Python code in the virtual environment
948 |         python_command = f"python -c \"import base64; exec(base64.b64decode('{encoded_code}').decode('utf-8'))\""
949 |         result = await self.venv_cmd(venv_name, python_command)
950 |         
951 |         # Parse the output to extract the payload
952 |         start_marker = "<<<VENV_EXEC_START>>>"
953 |         end_marker = "<<<VENV_EXEC_END>>>"
954 | 
955 |         # Print original stdout
956 |         print(result.stdout[:result.stdout.find(start_marker)])
957 |         
958 |         if start_marker in result.stdout and end_marker in result.stdout:
959 |             start_idx = result.stdout.find(start_marker) + len(start_marker)
960 |             end_idx = result.stdout.find(end_marker)
961 |             
962 |             if start_idx < end_idx:
963 |                 output_json = result.stdout[start_idx:end_idx]
964 | 
965 |                 try:
966 |                     # Decode and deserialize the output payload from JSON
967 |                     output_payload = json.loads(output_json)
968 |                 except Exception as e:
969 |                     raise Exception(f"Failed to decode output payload: {e}")
970 |                 
971 |                 if output_payload["success"]:
972 |                     return output_payload["result"]
973 |                 else:
974 |                     # Recreate and raise the original exception
975 |                     error_info = output_payload["error"]
976 |                     error_class = eval(error_info["type"])
977 |                     raise error_class(error_info["message"])
978 |             else:
979 |                 raise Exception("Invalid output format: markers found but no content between them")
980 |         else:
981 |             # Fallback: return stdout/stderr if no payload markers found
982 |             raise Exception(f"No output payload found. stdout: {result.stdout}, stderr: {result.stderr}")
983 | 
```
Page 17/20FirstPrevNextLast