QwenPaw Pet (plugin + desktop runtime)
Full-stack QwenPaw plugin: backend hooks, console UI sidebar, and the
desktop pet runtime itself (qwenpaw_pet_desktop/). Installing this
plugin gives you everything: the Qt floating pet window, the local HTTP
bridge on 127.0.0.1:8765, and the QwenPaw side hooks that emit
lifecycle events into it.
plugins/qwenpaw-pet/
├── plugin.json # manifest (id, hooks entry, dependencies)
├── plugin.py # backend: startup / shutdown hooks + monkey patches
├── emitter.py # fire-and-forget HTTP client + autostart spawn
├── router.py # plugin HTTP routes under /api/qwenpaw-pet/...
├── pet_paths.py # plugin-local helpers (WORKING_DIR, list pets)
├── patch_runner.py # monkey patch AgentRunner.query_handler
├── patch_approval.py # monkey patch approval service hooks
├── frontend/ # Vite + React console UI source
├── dist/index.js # built console UI bundle (build artifact, committed; regenerated via `npm run build`)
└── qwenpaw_pet_desktop/ # the Qt + FastAPI desktop runtime (embedded)
├── app.py # Qt main loop + uvicorn in a background thread
├── server.py # FastAPI app factory (/event, /pet, /bubble, ...)
├── window.py # PetWindow: frameless translucent always-on-top
├── sprites.py # 8x9 atlas constants + event→state mapping
├── runtime.py # paths, PID, token, atomic JSON helpers
├── pet_package.py # validate / install / hot-switch pet packages
├── cli.py # `python -m qwenpaw_pet_desktop` subcommands
└── assets/default-pet/snowpaw/ # default pet manifest (asset fetched on first use)
plugin.py injects the plugin directory into sys.path at import
time, so qwenpaw_pet_desktop is importable from QwenPaw's Python
process without any pip install step. See
qwenpaw_pet_desktop/README.md for
internals of the desktop runtime itself.
Install
Two equivalent ways:
-
From the QwenPaw console — open the plugin management page and install
qwenpaw-petlike any other QwenPaw plugin. Three input shapes are accepted:- point it at a local folder,
- upload a
.zip, or - paste a plugin URL (e.g. a GitHub release / raw archive URL) and let the console download and unpack it for you.
-
From the shell — run:
qwenpaw plugin install ./plugins/qwenpaw-pet
Important
Restart QwenPaw after install — the backend only picks up new plugin code (hooks, HTTP routes) on startup. The browser console usually also needs a hard refresh (
Cmd+Shift+R/Ctrl+Shift+R) to drop the cacheddist/index.jsand pull in the new sidebar UI.
Python dependencies
QwenPaw's interpreter needs these packages on its sys.path (declared
in plugin.json's dependencies):
httpx>=0.27— fire-and-forget HTTP clientfastapi>=0.110,uvicorn>=0.27— local HTTP bridgepython-multipart>=0.0.9— required by FastAPI to parse the Import-pet dropzone uploadspillow>=10.0— spritesheet validationpyside6-essentials>=6.6— Qt pet window. We only importQtCore/QtGui/QtWidgets, all of which live in Essentials, so there's no need to pull in the fullPySide6meta package (which also installsPySide6-Addons— ~800 MB of WebEngine, 3D, Multimedia, etc. that this plugin never touches).
If your QwenPaw install does not auto-resolve plugin dependencies, install them manually into the same environment:
pip install -r plugins/qwenpaw-pet/requirements.txt
PySide6-Essentials wheels exist only for Python 3.10–3.13. On 3.14 the pet window cannot start; QwenPaw itself will still run, the pet will just stay offline and the plugin logs a warning.
Running the desktop pet
The plugin's startup hook calls emitter.ensure_desktop_available(),
which spawns the desktop process on demand:
subprocess.Popen(
[sys.executable, "-m", "qwenpaw_pet_desktop.app",
"--port", "8765"],
start_new_session=True,
)
Manual control (any of these work):
# Foreground (logs to terminal)
python -m qwenpaw_pet_desktop.app --port 8765 --scale 0.58
# CLI subcommands (daemonized: PID file + log redirect)
python -m qwenpaw_pet_desktop start
python -m qwenpaw_pet_desktop status
python -m qwenpaw_pet_desktop stop
python -m qwenpaw_pet_desktop switch --pet-id snowpaw
# Send a test event once the window is up
curl -X POST http://127.0.0.1:8765/event \
-H 'Content-Type: application/json' \
-d '{"event":"query.running","text":"Thinking"}'
Hot-switch the running pet (no restart):
curl -X POST http://127.0.0.1:8765/pet \
-H 'Content-Type: application/json' \
-d '{"pet_id":"snowpaw"}'
# pet_id may be the pets/ *folder name* or the pet.json "id" when they differ
Autostart can be disabled with QWENPAW_PET_AUTOSTART=0.
Frontend (console sidebar)
Manifest field "frontend": "dist/index.js". The committed
dist/index.js is a build artifact generated from
frontend/src/index.tsx — keep it in lockstep with the source by
rebuilding whenever you edit frontend/src/:
cd frontend && npm install && npm run build
Note
dist/index.jsis committed (not gitignored) so the plugin installs cleanly without an npm toolchain on the target machine. Reviewers can ignore diffs todist/index.jsand look atfrontend/src/instead — the bundle is fully reproducible from the source via the command above. The frontend'sfrontend/.npmrcpins the publichttps://registry.npmjs.org/so anyone can reproduce the lockfile from outside Alibaba's intranet.
Reinstall the plugin, or copy dist/index.js into your
~/.copaw/plugins/qwenpaw-pet/dist/index.js and refresh the console.
Console host API compatibility
The bundle is built with react and react-dom marked external in
vite.config.ts and consumes both — plus antd — from the console
host at runtime (window.QwenPaw.host.React, host.antd,
host.getApiUrl, host.getApiToken). The shape of this contract is
declared in frontend/src/pineagents-host.d.ts. If the console host
bumps antd across a major version (e.g. drops Typography.Text,
renames message, etc.), the sidebar UI may need to be updated and
the bundle rebuilt; the Python plugin is unaffected.
The UI adds a sidebar page Pet (/plugin/qwenpaw-pet/pets): lists
pets under <WORKING_DIR>/pets, Start desktop pet (calls POST /api/qwenpaw-pet/desktop/start), Switch (hot-switch via POST /pet on the desktop), and Import pet — opens a modal with a
dropzone: drag a folder or a .zip onto it (drop area highlights
in blue), or click to choose a .zip via the system file picker.
Files are streamed as multipart/form-data to
POST /api/qwenpaw-pet/import-pet-upload, then validated and copied
into <WORKING_DIR>/pets/<id>/. The source (or the unzipped archive)
must contain pet.json and the spritesheet referenced by it
(1536×1872 webp); a single top-level subfolder is also accepted, which
is what macOS Finder's "Compress" produces.
QwenPaw plugin HTTP routes
GET /api/qwenpaw-pet/status
GET /api/qwenpaw-pet/pets
GET /api/qwenpaw-pet/pets/{folder}/spritesheet
POST /api/qwenpaw-pet/desktop/start
POST /api/qwenpaw-pet/switch-pet
POST /api/qwenpaw-pet/import-pet # JSON body — server-side path
POST /api/qwenpaw-pet/import-pet-upload # multipart/form-data — browser
POST /api/qwenpaw-pet/emit-test
/import-pet (JSON, for CLI / SDK use) takes
{"path": "<absolute path>", "replace": true} and reads a folder or
.zip that already exists on the server's filesystem.
/import-pet-upload (multipart, used by the dropzone in the UI)
takes one or more files in the files field plus a replace form
field. Two shapes:
- a single
.zipfile — extracted server-side (zip-slip protected), - one or more files whose
filenameis a relative path (webkitRelativePath-style) — written into a tempdir to recreate the folder structure.
Both paths share the same install logic. The package must contain
pet.json + the spritesheet it references (defaults to
spritesheet.webp, 1536×1872). The manifest id is checked against
^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$ before becoming a folder name
under pets/. Returns 409 when the pet already exists and replace
is false.
Desktop runtime HTTP API (127.0.0.1:8765)
GET /health # liveness + process state
GET /state # current state.json snapshot
GET /bubble # current bubble.json snapshot
GET /event # list valid state names
POST /event # drive the pet (event + state mapping)
POST /bubble # replace bubble text (200 char cap)
POST /pet # hot-switch pet (pet_id or pet_dir)
Mutating endpoints (POST /event, POST /bubble, POST /pet) require
X-QwenPaw-Pet-Token: <runtime/update-token> by default — the bundled
QwenPaw plugin reads the token file automatically; standalone clients
must do the same. Set QWENPAW_PET_REQUIRE_TOKEN=0 to disable the check
(only recommended for trusted single-user development setups).
Pet package contract
A Codex-compatible pet folder needs:
<pet-dir>/pet.json # {"id":"...", "spritesheetPath":"spritesheet.webp"}
<pet-dir>/spritesheet.webp # exactly 1536 x 1872 (8 cols x 9 rows of 192x208)
Row layout (driven by qwenpaw_pet_desktop/sprites.py):
0 idle 6 frames
1 running-right 8 frames
2 running-left 8 frames
3 waving 4 frames
4 jumping 5 frames
5 failed 8 frames
6 waiting 6 frames
7 running 6 frames
8 review 6 frames
Default pet shipped with this plugin: Snowpaw
(qwenpaw_pet_desktop/assets/default-pet/snowpaw/).
Only the tiny pet.json manifest is committed; the 1.6 MB
spritesheet.webp is downloaded from a CDN the first time the pet is
installed and cached under ~/.qwenpaw-pet/cache/snowpaw-spritesheet.webp
for subsequent runs. Override the source with QWENPAW_PET_SNOWPAW_URL
(e.g. point at an internal mirror or a file:// URL for offline
installs). To ship the atlas inside the plugin instead, drop a valid
spritesheet.webp into
qwenpaw_pet_desktop/assets/default-pet/snowpaw/ and the bundled copy
will take precedence over the cache and the network fetch.
Backend hooks
plugin.py registers, via the documented PluginApi:
register_startup_hook— patchesAgentRunner.query_handlerand the approval service, autostarts the desktop runtime, then emitsqwenpaw.startup. Patch failures (e.g. an upstream rename ofAgentRunner/ApprovalService) are reported vialogger.exceptionso a broken plugin install does not stay silently dead.register_shutdown_hook— emitsqwenpaw.shutdown, terminates the pet desktop process that this QwenPaw process has adopted (either autostarted by the plugin or already healthy on startup /desktop/start; controlled byQWENPAW_PET_STOP_ON_SHUTDOWN), and restores the patched class methods. So when the user exits QwenPaw the floating pet exits with it, including the case where the pet was a leftover from a previous QwenPaw run.register_http_router— mountsrouter.pyunder/qwenpaw-pet.
Environment variables
| Var | Purpose | Default |
|---|---|---|
QWENPAW_PET_DESKTOP_URL |
Full base URL for the plugin → desktop HTTP bridge. If set, port auto-fallback is disabled (URL and listener must agree). | unset ⇒ derive from host + port below |
QWENPAW_PET_DESKTOP_HOST |
Bind address when spawning (--host) |
127.0.0.1 |
QWENPAW_PET_DESKTOP_PORT |
Preferred port when spawning; if busy, the plugin scans upward (unless DESKTOP_URL is set or STRICT is on) |
8765 |
QWENPAW_PET_DESKTOP_STRICT_PORT |
1 ⇒ never auto-pick another port (fail with EADDRINUSE if taken) |
0 |
QWENPAW_PET_DESKTOP_SCALE |
Spawn-time scale (e.g. 0.58) |
unset |
QWENPAW_PET_DESKTOP_PET_DIR |
Spawn-time pet folder override | unset |
QWENPAW_PET_TOKEN_PATH |
Path to the local update token | ~/.qwenpaw-pet/runtime/update-token |
QWENPAW_PET_REQUIRE_TOKEN |
0 ⇒ desktop skips the token check on mutating endpoints (anything else, including unset, enforces it) |
1 |
QWENPAW_PET_AUTOSTART |
0 ⇒ plugin will not spawn the desktop |
1 |
QWENPAW_PET_STOP_ON_SHUTDOWN |
0 ⇒ leave the pet desktop running after QwenPaw exits. Default: terminate any pet desktop QwenPaw has adopted (either by autostarting it or by seeing it healthy at startup / explicit desktop/start). |
1 |
QWENPAW_PET_HOME |
Runtime dir (PID file, log, cache, token) | ~/.qwenpaw-pet/ |
QWENPAW_PET_SNOWPAW_URL |
CDN URL for snowpaw's spritesheet.webp (downloaded once on first install) |
Alicdn-hosted default |
QWENPAW_WORKING_DIR / COPAW_WORKING_DIR |
Where pets/ lives |
falls back to ~/.copaw then ~/.qwenpaw |
Pet desktop log / common errors
~/.qwenpaw-pet/runtime/pet-desktop.log captures stderr from the spawned
desktop process.
-
ModuleNotFoundError: No module named 'PySide6'— install Qt into the same Python as QwenPaw:pip install "pyside6-essentials>=6.6"(seeplugin.json/requirements.txt). -
[Errno 48] address already in use(uvicorn) — something else is bound to the configured port (often a leftover pet or another app). Either stop that process, setQWENPAW_PET_DESKTOP_PORTto a free port, or unsetQWENPAW_PET_DESKTOP_URLand let the plugin auto-pick the next free port after the preferred one.
The effective listen URL is mirrored under
~/.qwenpaw-pet/runtime/desktop-bridge.json (url field) so the plugin can
find the bridge after a port change.