diff --git a/README.md b/README.md index 0fef4b8..aa5e979 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,15 @@ ZIP remains portable to Codex and other Agent Skills clients. Because the same s changes both archives, an owned Skill release must also bump and publish its owner Plugin; release coverage verifies the deterministic bytes of both. +`convax.plugin/5` adds transport-neutral host capabilities, including a sandboxed +desktop pet feature. One Pet feature Plugin uses the +`convax.plugin-capability/1` compatibility pair and contributes static overlay and +settings surfaces plus a `convax.pet-library/1` packaged collection through +`contributes.pet`. The surfaces use the scoped `convax.pet-host/1` protocol; Convax +retains only the native window, content-free activity projection, validated +navigation, installed asset serving, and bounded persistence. See the working package in +[`packages/plugins/convax-pet`](packages/plugins/convax-pet). + See the working example in [`packages/plugins/hello-convax`](packages/plugins/hello-convax), then read: @@ -116,10 +125,10 @@ Open **Settings → Skills and Plugins** in a compatible Convax build. The catal loaded from the public Registry above; selecting **Install Plugin** or **Install Skill** sends only the package id to Convax main, which downloads and validates the corresponding immutable Release ZIP. -If a v2, v3, or v4 Plugin declares Registry companions, the same install transaction selects +If a v2, v3, v4, or v5 Plugin declares Registry companions, the same install transaction selects only the exact local platform/architecture artifact and verifies its immutable URL, byte count, and SHA-256 separately from the static ZIP. -For v4, Plugin-owned Skills are admitted and removed in that same Plugin transaction; +For v4 and v5, Plugin-owned Skills are admitted and removed in that same Plugin transaction; they are never an independent Convax install action. The `microvoid/convax-plugins` repository, Registry, and Release assets are public @@ -191,7 +200,7 @@ entries only. Third-party Plugin ZIPs are inert. Web surfaces are static HTML/CSS/JavaScript rendered by Convax in an iframe with exactly `sandbox="allow-scripts"`; they cannot contain native executables, Node/Electron code, network permissions, or a generic -host bridge. A v2, v3, or v4 Tool Plugin may name a separately installed external command. +host bridge. A v2, v3, v4, or v5 Tool Plugin may name a separately installed external command. Convax resolves and fingerprints it during explicit Plugin install/update; that transaction is consent to the exact binding, so later calls do not show a separate command prompt. It never becomes part of the ZIP. A Registry companion is an independent immutable Release asset, diff --git a/README.zh-CN.md b/README.zh-CN.md index 3e5a0c0..bdccef4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,6 +64,13 @@ manifest 为生成能力和/或固定服务动作声明一个单独安装的裸 Agent Skills 的客户端使用。由于同一份源码会同时改变两个压缩包,发布插件拥有的技能时 必须同步提升并发布所属插件版本;发布覆盖检查会校验双方的确定性产物字节。 +`convax.plugin/5` 新增与传输方式无关的宿主能力,其中包括沙箱化桌面宠物功能。一个 +Pet 功能插件使用 `convax.plugin-capability/1` 兼容性组合,通过 `contributes.pet` +提供静态悬浮窗、设置页面和 `convax.pet-library/1` 内置宠物库。页面通过受限的 +`convax.pet-host/1` 协议使用宿主能力;Convax 仅保留原生窗口、无内容活动投影、 +受控导航、已安装资产读取和有限持久化。可参考完整示例 +[`packages/plugins/convax-pet`](packages/plugins/convax-pet)。 + 可以先阅读完整示例 [`packages/plugins/hello-convax`](packages/plugins/hello-convax),然后参考: @@ -95,9 +102,9 @@ Agent Skills 的客户端使用。由于同一份源码会同时改变两个压 在兼容版本的 Convax 中打开“设置 → 技能与插件”。能力目录从上面的公开 Registry 加载。点击安装插件或安装技能后,渲染进程只会把包标识传给主进程,由主进程下载并 校验对应的不可变 Release ZIP。 -若 v2、v3 或 v4 插件声明了 Registry companion,同一次安装会只选择当前平台和架构的精确工件, +若 v2、v3、v4 或 v5 插件声明了 Registry companion,同一次安装会只选择当前平台和架构的精确工件, 并在静态 ZIP 之外独立校验其不可变 URL、字节数和 SHA-256。 -对于 v4,插件拥有的技能也在同一插件事务中接纳和移除,不能在 Convax 中独立安装或卸载。 +对于 v4 和 v5,插件拥有的技能也在同一插件事务中接纳和移除,不能在 Convax 中独立安装或卸载。 `microvoid/convax-plugins` 仓库、Registry 和 Release 资源都是公开的,不需要 GitHub 账号或令牌。主应用仓库 `microvoid/convax` 可以继续保持私有,不会影响包安装。 @@ -162,7 +169,7 @@ Registry 条目、为 ZIP 创建来源证明并发布 GitHub Release。Pages 工 第三方插件 ZIP 只能包含惰性文件。Web 界面只能是静态 HTML、CSS 和 JavaScript, 并由 Convax 放入仅带 `sandbox="allow-scripts"` 的 iframe 中运行;ZIP 不能包含原生 -可执行文件、Node/Electron 代码、网络权限或通用宿主桥接。v2、v3 或 v4 工具插件可以声明一个 +可执行文件、Node/Electron 代码、网络权限或通用宿主桥接。v2、v3、v4 或 v5 工具插件可以声明一个 单独安装的外部命令。Convax 会在用户明确安装或更新插件时独立解析并校验指纹;这次 操作即表示同意运行该精确绑定,后续调用不会再弹出本地命令确认。该命令不会进入 ZIP。 Registry companion 是独立且不可变的 Release 工件,仅在目标、大小和摘要全部精确校验后 diff --git a/bun.lock b/bun.lock index 0e95ff7..0ceb0cf 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,10 @@ "@microvoid/convax-codex-mcp": "workspace:*" } }, + "packages/plugins/convax-pet": { + "name": "@microvoid/convax-plugin-convax-pet", + "version": "0.2.1" + }, "packages/plugins/ffmpeg-tools": { "name": "@microvoid/convax-plugin-ffmpeg-tools", "version": "0.3.1", diff --git a/docs/packaging.md b/docs/packaging.md index ef1d4b1..0ffb626 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -32,6 +32,25 @@ license notice. Its generation and/or service contribution uses one declared must never appear anywhere below `package/`; validation and packing do not install, build, or execute companion source under `packages/tools/`. +## Pet feature Plugin assets + +A `convax.plugin/5` Pet feature package remains inert, offline Web content. Its ZIP +contains the manifest, license, documentation, static overlay and settings pages, +browser JavaScript/CSS, a `convax.pet-library/1` document, and its referenced PNG +or WebP atlases. The manifest's `contributes.pet` object names the packaged library +and both static surfaces. The ZIP must not contain a runtime, executable, +dependency tree, installer, remote script, or server. The Plugin uses the +transport-neutral `convax.plugin-capability/1` compatibility pair and the narrow +`convax.pet-host/1` surface protocol. + +For `spriteVersion: 2`, the sprite sheet is exactly 1536×1872 pixels: eight columns +of 192-pixel cells and nine rows of 208-pixel cells. The ordinary 2 MiB per-file +limit still applies. Keep asset paths package-relative and let Convax inspect +dimensions, format, transparency, and decoded image safety before installation. +The Plugin owns presentation, animation rules, collection, and selection. The host +owns the native floating window and exposes only bounded, content-free Agent +activity data. + The matching source metadata declares the reviewed tool directory and build output for each target. For example: @@ -69,7 +88,7 @@ mode, size and SHA-256 checks as a native companion. Native companions remain va ## Plugin-owned Skill composition -A `convax.plugin/4` manifest may declare `contributes.skills` entries such as +A `convax.plugin/4` or `convax.plugin/5` manifest may declare `contributes.skills` entries such as `{"name":"ffmpeg-canvas","path":"skills/ffmpeg-canvas"}`. The named Skill remains an independent workspace and standard portable Skill package. Its source metadata declares `ownerPluginId`. diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index e549d4a..973f597 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -7,7 +7,8 @@ DOM, use browser storage as shared application state, or access Node/Electron. `convax.plugin/2` through `convax.plugin/5` may instead be headless executable Tool Plugins. New executable Plugins should use v3, or v4 when they own -Skills; use v5 only when they contribute an LLM provider. Their ZIP still contains no executable code: the manifest names a separately distributed bare +Skills; use v5 for transport-neutral capabilities such as an LLM provider or Pet +feature. Their ZIP still contains no executable code: the manifest names a separately distributed bare `mcp-stdio` command for generation and/or fixed service actions. The Registry may bind that command to verified platform artifacts that Convax installs into host-owned storage. Explicit Plugin install/update authorizes that exact binding; @@ -21,8 +22,8 @@ documented fields are accepted. Source metadata must use the matching pair: - `convax.plugin/1` with `convax.plugin-host/1`; - `convax.plugin/2` with `convax.plugin-host/2`; -- `convax.plugin/3` with `convax.plugin-host/3`. -- `convax.plugin/4` with `convax.plugin-host/4`. +- `convax.plugin/3` with `convax.plugin-host/3`; +- `convax.plugin/4` with `convax.plugin-host/4`; - `convax.plugin/5` with `convax.plugin-capability/1`. The v1 schema is static-only: @@ -51,9 +52,55 @@ a companion `SKILL.md` inside the same Plugin ZIP; installing it remains an expl independent user action. This legacy field is available only through v3. Do not use it for a Skill whose lifecycle belongs to its Plugin. +## Pet contribution + +`convax.plugin/5` adds a sandboxed Pet feature contribution. A package contributes +one Pet feature Plugin and +owns its static overlay, settings, packaged collection, animation rules, and +selection. Its `convax.plugin-capability/1` compatibility label describes manifest +support; the surfaces use the separate `convax.pet-host/1` protocol: + +The `contributes.pet` object declares the library and both static feature surfaces: + +```json +{ + "schema": "convax.plugin/5", + "id": "convax-pet", + "name": "Convax Pet", + "description": "A local desktop companion and pet library for Convax activity.", + "version": "0.2.1", + "capabilities": [ + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write" + ], + "contributes": { + "pet": { + "library": "pet-library.json", + "overlay": "pet/index.html", + "settings": "settings/index.html", + "protocol": "convax.pet-host/1" + } + } +} +``` + +The `convax.pet-library/1` document contains one to 64 unique pet entries. Each +entry supplies `id`, `displayName`, `description`, package-relative `spritesheet`, +`spriteVersion: 2`, and `alt`. Every atlas is a 1536×1872 PNG or WebP containing +eight 192×208 cells across and nine state rows in this order: `idle`, +`running-right`, `running-left`, `waving`, `jumping`, `failed`, `waiting`, +`running`, and `review`. + +The settings and overlay pages run with no Node, Electron, remote network, native +path, or arbitrary IPC access. Their surface-scoped `convax.pet-host/1` ports expose +only content-free activity, validated navigation, overlay movement, preferences, +and wake/tuck lifecycle. Installation never wakes the pet automatically. New pets +ship as library entries in a new version of the same feature Plugin. + ## Plugin-owned Skills -`convax.plugin/4` replaces the ambiguous singular `skill` field with explicit +`convax.plugin/4` and `convax.plugin/5` replace the ambiguous singular `skill` field with explicit Plugin-owned Skill contributions. The owner must still provide a real Plugin capability—such as a sandboxed Canvas renderer, an executable generation/service runtime, or a renderer-mediated generation action—beyond merely wrapping a Skill: @@ -272,9 +319,12 @@ The v5 compatibility pair deliberately uses the independently versioned ## Host connection -Convax transfers one fresh `MessagePort` to each mounted Plugin node. Accept it only -from `window.parent`, for the host protocol matching the manifest major (`/1`, -`/2`, `/3`, `/4`, or `/5`), the exact Plugin id, and only once: +Convax transfers one fresh `MessagePort` to each mounted Plugin node using the +versioned `convax.plugin-host/1` through `/4` protocols. Accept it only from +`window.parent`, for the host protocol matching the manifest major, the exact +Plugin id, and only once. The transport-neutral v5 compatibility label does not by +itself grant this Canvas port. A Pet feature provider instead receives a separate +`convax.pet-host/1` port only on its declared overlay and settings surfaces: ```js const PROTOCOL = "convax.plugin-host/1"; @@ -341,5 +391,5 @@ host operations. No remote scripts/assets, iframe network APIs, popups, downloads, eval-generated code, native/WASM executables, packaged Node servers, filesystem paths, secrets, telemetry, service workers, or generic method forwarding. Do not edit `.convax` -files. A v2, v3, or v4 external runtime is a separately installed and authorized tool, never a +files. A v2, v3, v4, or v5 external runtime is a separately installed and authorized tool, never a Plugin ZIP asset. Use host capabilities only. diff --git a/docs/registry-spec.md b/docs/registry-spec.md index 82867be..9a18f75 100644 --- a/docs/registry-spec.md +++ b/docs/registry-spec.md @@ -56,11 +56,27 @@ rejected. Skill compatibility is exactly `{"skillSchema":"opencode.skill/1"}`. Artifact objects contain only `url`, `size`, and lowercase hex `sha256`; URLs always target `microvoid/convax-plugins` Release assets. +## Pet feature Plugins + +One Pet feature Plugin is a `convax.plugin/5` capability published through the +normal Plugin Registry item. Its complete embedded manifest contains +`contributes.pet` with package-relative `library`, `overlay`, and `settings` paths +plus `protocol: "convax.pet-host/1"`. It requests exactly `pet.activity.read`, +`pet.activity.open`, and `pet.preferences.write`, and has no runtime or companion +executable. The `convax.plugin-capability/1` compatibility label remains the +transport-neutral admission contract. + +Clients validate both static surface entries, the strict `convax.pet-library/1` +document, and every referenced atlas before activation. Installation does not +imply waking the pet. The Plugin owns presentation and its packaged collection; +Convax owns only native windowing, activity projection, navigation validation, and +bounded persistence. + ## Plugin-owned Skills A Skill item may additionally contain `ownerPluginId`. This is lifecycle metadata for Convax, not an Agent Skills field. The id must resolve to a Plugin item whose -`convax.plugin/4` manifest contains a matching `contributes.skills` item. The +`convax.plugin/4` or `convax.plugin/5` manifest contains a matching `contributes.skills` item. The Registry is rejected if either side is missing. Convax may show an owned Skill as a normal Skill detail with a “Provided by” @@ -83,7 +99,7 @@ Release entry; a new Skill entry cannot be published beside stale owner Plugin b ## Verified companion executables -An external v2, v3, or v4 runtime is distributed beside, never inside, its static Plugin ZIP. +An external v2, v3, v4, or v5 runtime is distributed beside, never inside, its static Plugin ZIP. Its Plugin item has the following optional strict field: ```json diff --git a/docs/superpowers/plans/2026-07-22-convax-pet-feature-plugin.md b/docs/superpowers/plans/2026-07-22-convax-pet-feature-plugin.md new file mode 100644 index 0000000..2a17d74 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-convax-pet-feature-plugin.md @@ -0,0 +1,771 @@ +# Convax Pet Feature Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the pet prototype into one sandboxed Convax Pet feature Plugin that owns Violet, its packaged library, overlay UI, settings UI, animation rules, and selection while Convax retains only generic native and security-sensitive host primitives. + +**Architecture:** `convax.plugin/5` declares one singleton Pet provider with package-relative `library`, `overlay`, and `settings` entries and a `convax.pet-host/1` protocol. Static Plugin JavaScript renders both surfaces and consumes narrow host APIs; Convax validates the package, mounts the surfaces, projects content-free activity, resolves navigation, controls the native window, and stores bounded preferences. No legacy pet, Goku, directory, `pet.json`, or raw spritesheet import remains. + +**Tech Stack:** Bun 1.3, ECMAScript modules, JSON Schema, TypeScript, React 19 for the Convax settings host, Electron BrowserWindow/session/protocol APIs, existing Convax plugin asset protocol and test infrastructure. + +--- + +## File ownership map + +### `convax-plugins` + +- `schemas/convax-plugin-manifest-v5.schema.json`: strict feature contribution and Pet capabilities. +- `tooling/lib.mjs`: parser plus package-library and atlas validation. +- `tooling/plugin-v5.test.js`: schema/parser/package byte coverage. +- `packages/plugins/convax-pet/package/manifest.json`: feature entry points and capabilities. +- `packages/plugins/convax-pet/package/pet-library.json`: immutable `convax.pet-library/1` catalog. +- `packages/plugins/convax-pet/package/assets/pet-library.js`: browser-consumable catalog matching the JSON source. +- `packages/plugins/convax-pet/package/assets/activity.js`: activity priority and animation rules. +- `packages/plugins/convax-pet/package/assets/pet-host.js`: bounded MessagePort client. +- `packages/plugins/convax-pet/package/pet/*`: Plugin-owned overlay surface. +- `packages/plugins/convax-pet/package/settings/*`: Plugin-owned settings surface. +- `packages/plugins/convax-pet/*.test.js`: pure library, activity, protocol, and static-surface tests. +- `README.md`, `README.zh-CN.md`, `docs/plugin-authoring.md`, `docs/packaging.md`, `docs/registry-spec.md`: revised feature Plugin documentation. +- `registry/config.json`: publication sequence bump. + +### `/Users/bytedance/src/convax` + +- `packages/desktop/src/plugin-contracts.ts`: host mirror of the strict contribution. +- `packages/desktop/src/plugin-contracts.test.ts`: parser regression coverage. +- `packages/desktop/src/main/plugin-manager.ts`: entry/library/atlas install validation. +- `packages/desktop/src/main/plugin-manager.test.ts`: real package validation tests. +- `packages/desktop/src/pet-contracts.ts`: `convax.pet-host/1` request/event and provider snapshots. +- `packages/desktop/src/main/pet-provider-controller.ts`: singleton provider and wake lifecycle. +- `packages/desktop/src/main/pet-host-connection.ts`: per-surface allowlists and dispatch. +- `packages/desktop/src/main/pet-state-store.ts`: provider, window, acknowledgement, and bounded Plugin preference state. +- `packages/desktop/src/main/pet-window.ts`: loads the installed overlay entry instead of host UI. +- `packages/desktop/src/main/pet-session.ts`: registers installed Plugin assets on the isolated overlay session. +- `packages/desktop/src/main/pet-ipc.ts`: trusted renderer/overlay bridge without raw import. +- `packages/desktop/src/preload/pet.ts`: fixed top-level overlay connector. +- `packages/desktop/src/preload/index.ts`: settings-host bridge. +- `packages/desktop/src/renderer/pet-settings-host.tsx`: sandboxed settings iframe and port relay. +- `packages/desktop/src/renderer/settings-view.tsx`: conditionally mounts the provider surface. +- `packages/desktop/src/main/index.ts` and `application-lifecycle.ts`: compose and dispose the provider. +- `packages/desktop/electron.vite.config.ts`: stop bundling host-owned pet renderer; retain the fixed preload. +- Existing `agent-activity-controller.ts`: retained as the content-free host projection. +- Existing host-owned `renderer/pet/*`, `renderer/pet-settings.tsx`, custom-pet branches in `pet-controller.ts`, and `convax-pet-asset` protocol: removed after replacement tests are green. + +--- + +### Task 1: Replace the repository Pet manifest contract + +**Files:** +- Modify: `schemas/convax-plugin-manifest-v5.schema.json` +- Modify: `tooling/lib.mjs` +- Modify: `tooling/plugin-v5.test.js` + +- [ ] **Step 1: Write failing feature-contribution parser tests** + +Replace the test Pet fixture with: + +```js +function petManifest(overrides = {}) { + return { + schema: "convax.plugin/5", + id: "convax-pet", + name: "Convax Pet", + description: "A local desktop companion and pet library.", + version: "0.2.0", + capabilities: ["pet.activity.read", "pet.activity.open", "pet.preferences.write"], + contributes: { + pet: { + library: "pet-library.json", + overlay: "pet/index.html", + settings: "settings/index.html", + protocol: "convax.pet-host/1", + }, + }, + ...overrides, + } +} +``` + +Assert exact parsed output, HTML extensions for both entries, JSON extension for +the library, fixed protocol, exact required Pet capabilities, rejection of the +old `spritesheet` shape, unknown fields, traversal, URLs, duplicate capabilities, +runtime declarations, and `convax.plugin/1` through `/4`. + +- [ ] **Step 2: Run the focused tests and confirm RED** + +Run: + +```sh +bun test tooling/plugin-v5.test.js +``` + +Expected: failures because `parsePetV5` still requires one spritesheet and the +Pet capability strings are not admitted. + +- [ ] **Step 3: Implement the strict schema and parser** + +Change the Pet schema to: + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["library", "overlay", "settings", "protocol"], + "properties": { + "library": { "allOf": [{ "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, { "pattern": "\\.json$" }] }, + "overlay": { "allOf": [{ "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, { "pattern": "\\.html$" }] }, + "settings": { "allOf": [{ "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, { "pattern": "\\.html$" }] }, + "protocol": { "const": "convax.pet-host/1" } + } +} +``` + +Add the three Pet capability strings to the v5 capability set. Parse the four +exact fields with `parseRelativePath`, enforce extensions and protocol, and require +the exact three capabilities whenever `contributes.pet` is present. Reject a Pet +contribution combined with `runtime`. + +- [ ] **Step 4: Run the focused tests and confirm GREEN** + +Run `bun test tooling/plugin-v5.test.js`. + +Expected: all v5 tests pass, including unrelated Project/Canvas/LLM fixtures. + +- [ ] **Step 5: Commit the contract** + +```sh +git add schemas/convax-plugin-manifest-v5.schema.json tooling/lib.mjs tooling/plugin-v5.test.js +git commit -m "feat(plugin): define pet feature surfaces" +``` + +### Task 2: Validate packaged pet libraries and every atlas + +**Files:** +- Modify: `tooling/lib.mjs` +- Modify: `tooling/plugin-v5.test.js` + +- [ ] **Step 1: Add failing package-library tests** + +Cover this exact library: + +```json +{ + "schema": "convax.pet-library/1", + "pets": [{ + "id": "violet", + "displayName": "Violet", + "description": "A pixel companion for Convax.", + "spritesheet": "assets/violet.webp", + "spriteVersion": 2, + "alt": "Violet, the Convax pixel companion" + }] +} +``` + +Test missing library, invalid UTF-8/JSON, unknown fields, empty pets, duplicate ID, +unsafe atlas path, missing atlas, extension/signature mismatch, wrong dimensions, +and valid PNG/WebP libraries with multiple pets. + +- [ ] **Step 2: Confirm RED** + +Run `bun test tooling/plugin-v5.test.js`. + +Expected: package validation still looks for `manifest.contributes.pet.spritesheet`. + +- [ ] **Step 3: Implement `validatePetPackageLibrary`** + +The function must find the declared library file in inert package entries, parse +strict UTF-8 JSON, validate the exact schema, deduplicate IDs and paths, and call +the existing image-dimension/signature inspector for each referenced atlas. Return +a cloned bounded library object; never execute package content. + +Wire it into package discovery in place of `validatePetPackageAsset`. + +- [ ] **Step 4: Confirm GREEN** + +Run `bun test tooling/plugin-v5.test.js`. + +Expected: all library and atlas cases pass. + +- [ ] **Step 5: Commit library validation** + +```sh +git add tooling/lib.mjs tooling/plugin-v5.test.js +git commit -m "feat(plugin): validate packaged pet libraries" +``` + +### Task 3: Add the Plugin-owned library and activity model + +**Files:** +- Create: `packages/plugins/convax-pet/package/pet-library.json` +- Create: `packages/plugins/convax-pet/package/assets/pet-library.js` +- Create: `packages/plugins/convax-pet/package/assets/activity.js` +- Create: `packages/plugins/convax-pet/pet-library.test.js` +- Create: `packages/plugins/convax-pet/activity.test.js` +- Modify: `packages/plugins/convax-pet/package.json` + +- [ ] **Step 1: Write failing pure-module tests** + +Test that the browser library exactly matches `pet-library.json`, resolves +`violet`, falls back to `violet` for an absent selection, and does not expose a +mutable shared object. Test exact activity priority and mappings: + +```js +export const priority = { "needs-input": 0, blocked: 1, ready: 2, running: 3 } +export function animationFor(activity) { + if (!activity) return "idle" + if (activity.state === "needs-input") return activity.subtype === "permission" ? "review" : "waiting" + if (activity.state === "blocked") return "failed" + if (activity.state === "ready") return "waving" + return "running" +} +``` + +- [ ] **Step 2: Confirm RED** + +Run `bun test packages/plugins/convax-pet/pet-library.test.js packages/plugins/convax-pet/activity.test.js`. + +Expected: modules are missing. + +- [ ] **Step 3: Implement immutable packaged data and pure rules** + +Add Violet to both representations. Export `petLibrary`, `selectedPet`, +`orderedActivities`, `animationFor`, the nine animation definitions, and bounded +status copy. Freeze exported data and return clones at state boundaries. + +Add `"test": "bun test"` to the workspace scripts. + +- [ ] **Step 4: Confirm GREEN** + +Run the same focused tests. + +Expected: all library and activity tests pass. + +- [ ] **Step 5: Commit Plugin product data** + +```sh +git add packages/plugins/convax-pet +git commit -m "feat(plugin): add packaged pet library" +``` + +### Task 4: Implement the bounded Pet host client + +**Files:** +- Create: `packages/plugins/convax-pet/package/assets/pet-host.js` +- Create: `packages/plugins/convax-pet/pet-host.test.js` + +- [ ] **Step 1: Write failing protocol-client tests** + +Use fake `window` and `MessagePort` objects to verify that the client accepts one +connection only when `event.source` is the expected parent/window, the protocol is +`convax.pet-host/1`, the surface and Plugin ID match, and exactly one port exists. +Verify request IDs, response matching, event delivery, disconnect rejection, and +the 64-pending-request bound. + +- [ ] **Step 2: Confirm RED** + +Run `bun test packages/plugins/convax-pet/pet-host.test.js`. + +Expected: the client module is missing. + +- [ ] **Step 3: Implement the client** + +Export `connectPetHost({ pluginId, surface, source })`. The returned API must expose +only `request(method, params)`, `subscribe(event, listener)`, and `close()`. It must +validate envelopes, cap string/error sizes, remove listeners on close, and never +use global `postMessage` after accepting the transferred port. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run the focused test, then: + +```sh +git add packages/plugins/convax-pet/package/assets/pet-host.js packages/plugins/convax-pet/pet-host.test.js +git commit -m "feat(plugin): add scoped pet host client" +``` + +### Task 5: Move the overlay UI into the Plugin + +**Files:** +- Create: `packages/plugins/convax-pet/package/pet/index.html` +- Create: `packages/plugins/convax-pet/package/pet/app.js` +- Create: `packages/plugins/convax-pet/package/pet/styles.css` +- Create: `packages/plugins/convax-pet/overlay.test.js` + +- [ ] **Step 1: Write failing overlay behavior tests** + +Extract and test pure helpers for frame selection, reduced motion, drag threshold, +keyboard actions, status copy, visible activities, and navigation sequencing. +Assert that the HTML loads only local CSS/modules and contains no inline script, +remote URL, form, webview, or Node/Electron reference. + +- [ ] **Step 2: Confirm RED** + +Run `bun test packages/plugins/convax-pet/overlay.test.js`. + +Expected: Plugin overlay files are missing. + +- [ ] **Step 3: Implement the Plugin overlay** + +Port the existing Convax layout and styling into static DOM code. Read the selected +pet from the packaged library and preferences supplied by the host. Subscribe to +`activity.changed`, render a collapsed 176×176 surface or a 356×320 tray, use the +8×9 atlas, request `overlay.move`, `overlay.setExpanded`, and `activity.open`, and +create no animation timer when `matchMedia('(prefers-reduced-motion: reduce)')` +matches. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run the focused Plugin tests, then: + +```sh +git add packages/plugins/convax-pet/package/pet packages/plugins/convax-pet/overlay.test.js +git commit -m "feat(plugin): own floating pet surface" +``` + +### Task 6: Move the settings and collection UI into the Plugin + +**Files:** +- Create: `packages/plugins/convax-pet/package/settings/index.html` +- Create: `packages/plugins/convax-pet/package/settings/app.js` +- Create: `packages/plugins/convax-pet/package/settings/styles.css` +- Create: `packages/plugins/convax-pet/settings.test.js` + +- [ ] **Step 1: Write failing settings tests** + +Test rendering every packaged library item, selected state, selection without +implicit wake, explicit wake/tuck, fallback to Violet for stale preferences, and a +disconnected error state. Assert there is no import, upload, delete, URL, or file +input control. + +- [ ] **Step 2: Confirm RED** + +Run `bun test packages/plugins/convax-pet/settings.test.js`. + +Expected: Plugin settings files are missing. + +- [ ] **Step 3: Implement the settings surface** + +Render Convax-aligned cards using package assets. Use only +`preferences.get`, `preferences.update`, and `lifecycle.setAwake`. Selection writes +`{ selectedPetId }`; wake remains a separate explicit action. Keep all user-facing +pet copy inside the Plugin bundle. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run the focused tests, then: + +```sh +git add packages/plugins/convax-pet/package/settings packages/plugins/convax-pet/settings.test.js +git commit -m "feat(plugin): own pet collection settings" +``` + +### Task 7: Publish the revised Plugin package contract + +**Files:** +- Modify: `packages/plugins/convax-pet/package/manifest.json` +- Modify: `packages/plugins/convax-pet/package/README.md` +- Modify: `packages/plugins/convax-pet/package.json` +- Modify: `packages/plugins/convax-pet/convax-package.json` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/plugin-authoring.md` +- Modify: `docs/packaging.md` +- Modify: `docs/registry-spec.md` +- Modify: `registry/config.json` +- Modify: `tooling/workspaces.test.js` +- Modify: `tooling/registry.test.js` + +- [ ] **Step 1: Write failing documentation/metadata assertions** + +Require version `0.2.0`, the three exact capabilities, four contribution fields, +the feature-Plugin ownership wording, packaged library documentation, and absence +of “pet-only inert sprite contribution” wording. + +- [ ] **Step 2: Confirm RED** + +Run: + +```sh +bun test tooling/workspaces.test.js tooling/registry.test.js tooling/plugin-v5.test.js +``` + +- [ ] **Step 3: Update package, docs, and catalog metadata** + +Set both package versions to `0.2.0`, point the manifest at +`pet-library.json`, `pet/index.html`, and `settings/index.html`, retain +`convax.plugin-capability/1` source compatibility, and increment the Registry +sequence exactly once. Document that future pets are bundled into the same Plugin. + +- [ ] **Step 4: Verify and commit the Plugin repository** + +Run: + +```sh +bun run validate -- --kind plugin --id convax-pet +bun test packages/plugins/convax-pet tooling/plugin-v5.test.js tooling/workspaces.test.js tooling/registry.test.js +bun run pack -- --kind plugin --id convax-pet +``` + +Then commit: + +```sh +git add README.md README.zh-CN.md docs packages/plugins/convax-pet registry/config.json schemas tooling bun.lock +git commit -m "feat(plugin): ship pet feature plugin" +``` + +### Task 8: Mirror and validate the feature contract in Convax + +**Files:** +- Modify: `packages/desktop/src/plugin-contracts.ts` +- Modify: `packages/desktop/src/plugin-contracts.test.ts` +- Modify: `packages/desktop/src/main/plugin-manager.ts` +- Modify: `packages/desktop/src/main/plugin-manager.test.ts` +- Create: `packages/desktop/src/main/pet-library.ts` +- Create: `packages/desktop/src/main/pet-library.test.ts` + +- [ ] **Step 1: Write failing host parser and installer tests** + +Mirror the Task 1 and Task 2 fixtures. Assert exact capabilities and contribution, +every required regular file, strict library JSON, unique IDs, safe package paths, +and every atlas inspection. Assert that the old one-spritesheet contribution and +raw runtime are rejected. + +- [ ] **Step 2: Confirm RED** + +Run: + +```sh +bun test packages/desktop/src/plugin-contracts.test.ts packages/desktop/src/main/plugin-manager.test.ts packages/desktop/src/main/pet-library.test.ts +``` + +- [ ] **Step 3: Implement mirrored parsing and inert validation** + +Define: + +```ts +export interface WebPluginPetContribution { + library: string + overlay: string + settings: string + protocol: "convax.pet-host/1" +} +``` + +Create a strict `parseInstalledPetLibrary` that returns immutable metadata and +validated package paths. Reuse `PetAssetInspector` for every atlas. Keep validation +generic and avoid checking `plugin.id === "convax-pet"`. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run the focused tests and desktop typecheck, then: + +```sh +git add packages/desktop/src/plugin-contracts.ts packages/desktop/src/plugin-contracts.test.ts packages/desktop/src/main/plugin-manager.ts packages/desktop/src/main/plugin-manager.test.ts packages/desktop/src/main/pet-library.ts packages/desktop/src/main/pet-library.test.ts +git commit -m "feat(desktop): validate pet feature providers" +``` + +### Task 9: Define and enforce `convax.pet-host/1` + +**Files:** +- Modify: `packages/desktop/src/pet-contracts.ts` +- Create: `packages/desktop/src/main/pet-host-connection.ts` +- Create: `packages/desktop/src/main/pet-host-connection.test.ts` + +- [ ] **Step 1: Write failing protocol tests** + +Test exact request envelopes, maximum IDs and payloads, overlay/settings method +allowlists, capability checks, provider generation binding, activity event delivery, +close behavior, and rejection of methods such as import, filesystem access, generic +IPC, or a settings-origin activity request. + +- [ ] **Step 2: Confirm RED** + +Run `bun test packages/desktop/src/main/pet-host-connection.test.ts`. + +- [ ] **Step 3: Implement the protocol dispatcher** + +Add discriminated TypeScript request/response/event types with protocol literal +`convax.pet-host/1`. Implement separate overlay and settings connections. Dispatch +only the five operation groups from the approved design, clone every result, and +invalidate pending requests when the installed provider digest changes. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run focused tests plus desktop typecheck, then: + +```sh +git add packages/desktop/src/pet-contracts.ts packages/desktop/src/main/pet-host-connection.ts packages/desktop/src/main/pet-host-connection.test.ts +git commit -m "feat(desktop): add scoped pet host protocol" +``` + +### Task 10: Replace pet inventory management with provider lifecycle + +**Files:** +- Create: `packages/desktop/src/main/pet-provider-controller.ts` +- Create: `packages/desktop/src/main/pet-provider-controller.test.ts` +- Modify: `packages/desktop/src/main/pet-state-store.ts` +- Modify: `packages/desktop/src/main/pet-state-store.test.ts` + +- [ ] **Step 1: Write failing provider lifecycle tests** + +Test no provider, one provider, deterministic singleton conflict rejection, +explicit wake, tuck, provider update generation change, provider uninstall, bounded +`selectedPetId`, stale selection left for Plugin fallback, activity subscription only +while awake, and persistence of display/acknowledgement state. + +- [ ] **Step 2: Confirm RED** + +Run: + +```sh +bun test packages/desktop/src/main/pet-provider-controller.test.ts packages/desktop/src/main/pet-state-store.test.ts +``` + +- [ ] **Step 3: Implement provider state** + +Replace `PetSelection` and custom-pet state with: + +```ts +interface PetPersistedState { + schema: "convax.pet-state/1" + awake: boolean + providerId?: string + preferences: { selectedPetId?: string } + displayId?: string + positions: Record + seen: Record +} +``` + +The controller selects the contribution generically, owns no pet library metadata, +and opens the provider overlay URL only after explicit wake. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run focused tests plus typecheck, then: + +```sh +git add packages/desktop/src/main/pet-provider-controller.ts packages/desktop/src/main/pet-provider-controller.test.ts packages/desktop/src/main/pet-state-store.ts packages/desktop/src/main/pet-state-store.test.ts +git commit -m "feat(desktop): manage pet feature provider" +``` + +### Task 11: Load Plugin content in the isolated native overlay + +**Files:** +- Modify: `packages/desktop/src/main/pet-window.ts` +- Modify: `packages/desktop/src/main/pet-window.test.ts` +- Modify: `packages/desktop/src/main/pet-session.ts` +- Modify: `packages/desktop/src/main/pet-session.test.ts` +- Modify: `packages/desktop/src/preload/pet.ts` +- Modify: `packages/desktop/electron.vite.config.ts` +- Modify: `packages/desktop/electron.vite.config.test.ts` + +- [ ] **Step 1: Write failing isolated-overlay tests** + +Require the window URL to be the selected provider's exact `convax-plugin://` overlay +entry, the `convax-plugin` handler to be registered on `convax-pet-overlay`, strict +same-provider navigation, fixed sandbox preload, no host pet renderer build entry, +and one crash restart before tuck. + +- [ ] **Step 2: Confirm RED** + +Run the four affected test files. + +Expected: the window still loads host `pet/index.html` and the session registers +`convax-pet-asset`. + +- [ ] **Step 3: Implement Plugin overlay loading** + +Reuse `createWebPluginAssetHandler` with a Pet-surface CSP/frame-ancestor option and +the installed manager resolver. Register/unregister it on the nonpersistent Pet +session. Keep display clamping, inactive show, drag, resize, permission denial, and +crash recovery. Change the preload from presentation logic to a fixed scoped host +connector. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run focused tests, desktop typecheck, and the desktop production build, then: + +```sh +git add packages/desktop/src/main/pet-window.ts packages/desktop/src/main/pet-window.test.ts packages/desktop/src/main/pet-session.ts packages/desktop/src/main/pet-session.test.ts packages/desktop/src/preload/pet.ts packages/desktop/electron.vite.config.ts packages/desktop/electron.vite.config.test.ts +git commit -m "feat(desktop): host plugin-owned pet overlay" +``` + +### Task 12: Mount the Plugin-owned settings surface + +**Files:** +- Create: `packages/desktop/src/renderer/pet-settings-host.tsx` +- Create: `packages/desktop/src/renderer/pet-settings-host.test.tsx` +- Modify: `packages/desktop/src/renderer/settings-view.tsx` +- Modify: `packages/desktop/src/renderer/settings-view.test.tsx` +- Modify: `packages/desktop/src/preload/index.ts` +- Modify: `packages/desktop/src/renderer/env.d.ts` + +- [ ] **Step 1: Write failing settings-host tests** + +Test that no provider hides the Pet section, one provider renders an iframe with +exact `sandbox="allow-scripts"`, the frame receives one settings-scoped port after +load, disconnects on unmount/provider change, and cannot request overlay activity +methods. Verify there is no host-rendered pet card or import button. + +- [ ] **Step 2: Confirm RED** + +Run: + +```sh +bun test packages/desktop/src/renderer/pet-settings-host.test.tsx packages/desktop/src/renderer/settings-view.test.tsx +``` + +- [ ] **Step 3: Implement the frame host** + +Use the installed provider settings URL and existing Plugin iframe navigation +guards. Transfer a fresh MessageChannel to the frame and relay only the settings +connection. Keep the host shell limited to section navigation, loading, and +provider-unavailable fallback. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run focused tests and desktop typecheck, then: + +```sh +git add packages/desktop/src/renderer/pet-settings-host.tsx packages/desktop/src/renderer/pet-settings-host.test.tsx packages/desktop/src/renderer/settings-view.tsx packages/desktop/src/renderer/settings-view.test.tsx packages/desktop/src/preload/index.ts packages/desktop/src/renderer/env.d.ts +git commit -m "feat(desktop): mount plugin pet settings" +``` + +### Task 13: Compose activity, navigation, IPC, and application lifecycle + +**Files:** +- Modify: `packages/desktop/src/main/pet-ipc.ts` +- Modify: `packages/desktop/src/main/pet-ipc.test.ts` +- Modify: `packages/desktop/src/main/index.ts` +- Modify: `packages/desktop/src/main/application-lifecycle.ts` +- Modify: `packages/desktop/src/main/application-lifecycle.test.ts` +- Modify: `packages/desktop/src/renderer/agent-panel.tsx` +- Modify: `packages/desktop/src/renderer/app-language.ts` + +- [ ] **Step 1: Write failing composition tests** + +Test trusted sender checks, port creation for exact provider generations, activity +snapshot/event forwarding, stale navigation rejection, focus/restore/open behavior, +mark-displayed acknowledgements, provider install/update/uninstall refresh, and +shutdown disposal order. Assert no file picker or custom-pet IPC channel remains. + +- [ ] **Step 2: Confirm RED** + +Run the affected main/lifecycle/agent tests. + +- [ ] **Step 3: Implement application composition** + +Wire `AgentActivityController` to the provider's overlay connection instead of the +host renderer. Route opaque activity open through the existing main-window restore +and navigation flow. Refresh the singleton provider on Plugin lifecycle changes. +Start global activity recovery only while an enabled provider is awake. Keep the +generic “Pets” section label in the host; move all pet-specific copy into the +Plugin. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run focused tests and desktop typecheck, then: + +```sh +git add packages/desktop/src/main/pet-ipc.ts packages/desktop/src/main/pet-ipc.test.ts packages/desktop/src/main/index.ts packages/desktop/src/main/application-lifecycle.ts packages/desktop/src/main/application-lifecycle.test.ts packages/desktop/src/renderer/agent-panel.tsx packages/desktop/src/renderer/app-language.ts +git commit -m "feat(desktop): compose pet feature plugin" +``` + +### Task 14: Remove superseded host product code and raw import + +**Files:** +- Delete: `packages/desktop/src/main/pet-controller.ts` +- Delete: `packages/desktop/src/main/pet-controller.test.ts` +- Delete: `packages/desktop/src/main/pet-asset-protocol.ts` +- Delete: `packages/desktop/src/main/pet-asset-protocol.test.ts` +- Delete: `packages/desktop/src/renderer/pet-settings.tsx` +- Delete: `packages/desktop/src/renderer/pet-settings.test.tsx` +- Delete: `packages/desktop/src/renderer/pet/index.html` +- Delete: `packages/desktop/src/renderer/pet/index.tsx` +- Delete: `packages/desktop/src/renderer/pet/pet-entry.test.ts` +- Delete: `packages/desktop/src/renderer/pet/pet-view.tsx` +- Delete: `packages/desktop/src/renderer/pet/pet-view.test.tsx` +- Delete: `packages/desktop/src/renderer/pet/styles.css` +- Modify: `packages/desktop/src/renderer/index.html` + +- [ ] **Step 1: Add a boundary regression test** + +Assert the Convax source tree contains no Violet string, animation table, +`importCustom`, `deleteCustom`, `custom-pet`, raw spritesheet picker, host pet card, +or `convax-pet-asset` scheme, while `AgentActivityController`, `PetWindow`, provider +controller, and host protocol remain. + +- [ ] **Step 2: Confirm RED** + +Run the boundary test and expect matches from the prototype. + +- [ ] **Step 3: Delete superseded files and references** + +Remove the host renderer bundle, raw import/storage branches, obsolete CSP scheme, +and old tests only after Tasks 8–13 are green. Preserve asset inspection because +the installer still validates packaged library atlases. + +- [ ] **Step 4: Confirm GREEN and commit** + +Run desktop typecheck and all Pet/Plugin tests, then: + +```sh +git add packages/desktop +git commit -m "refactor(desktop): remove host-owned pet product" +``` + +### Task 15: End-to-end verification and review + +**Files:** +- Modify only files required by failures found during verification. + +- [ ] **Step 1: Run complete `convax-plugins` verification** + +```sh +bun install --frozen-lockfile --ignore-scripts +bun run workspaces:build:packages +bun run validate +bun run workspaces:typecheck +bun run workspaces:test +bun run build:companions +bun test +bun run pack +bun run build:index +``` + +Expected: every command exits zero; the Convax Pet ZIP contains both surfaces, +library JSON, browser modules, Violet, manifest, README, and license at ZIP root. + +- [ ] **Step 2: Run complete Convax verification** + +```sh +bun run check +``` + +Expected: lint/typecheck/tests/package boundaries/production build/Electron smoke +all pass. Run the focused Pet suites separately if output truncation hides counts. + +- [ ] **Step 3: Run acceptance smoke** + +Install the locally packed `convax-pet` ZIP in a built Convax app, verify Plugin +settings ownership, select/wake Violet, simulate running/needs-input/ready/blocked, +navigate from the tray, toggle reduced motion, restart, and uninstall. Confirm no +file import control or network request exists. + +- [ ] **Step 4: Request two-scope review** + +Review `convax-plugins` for manifest/package/security correctness and Convax for +protocol/session/lifecycle correctness. Fix every Critical or Important finding +with a failing regression test first, rerun focused and full verification, then +commit fixes using conventional messages. + +- [ ] **Step 5: Confirm clean branches** + +```sh +git status --short +git log --oneline --decorate -5 +``` + +Expected: both `codex/convax-pet` branches are clean and based on their requested +`main` and `convax-next` branches respectively. diff --git a/docs/superpowers/plans/2026-07-22-convax-pet.md b/docs/superpowers/plans/2026-07-22-convax-pet.md new file mode 100644 index 0000000..9c80739 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-convax-pet.md @@ -0,0 +1,1068 @@ +# Convax Pet Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship an original Codex-style floating Convax pet as an inert `convax-pet` Plugin plus a generic, secure, cross-project desktop pet host. + +**Architecture:** `convax.plugin/5` gains an optional declarative `contributes.pet` value while retaining all existing v5 capabilities and its `convax.plugin-capability/1` compatibility. `@convax/agent-runtime` projects content-free session activity; Convax desktop aggregates it across projects, owns a hardened transparent window and local preferences, and resolves opaque activity navigation in the main process. The Plugin ZIP contains only its manifest, license, README, and Violet sprite atlas. + +**Tech Stack:** Bun, TypeScript, JSON Schema 2020-12, React 19, Electron 42, electron-vite, Tailwind/Convax UI tokens, Bun test, PNG/WebP sprite atlases. + +**Repositories and branches:** + +- `/Users/bytedance/src/convax-plugins` on `codex/convax-pet`, based on local `main`. +- `/Users/bytedance/src/convax` on `codex/convax-pet`, based on local `convax-next`. +- The user explicitly chose direct branches instead of worktrees. + +--- + +## File map + +### `/Users/bytedance/src/convax-plugins` + +- `schemas/convax-plugin-manifest-v5.schema.json`: public v5 manifest schema, including existing v5 fields and optional `pet`. +- `schemas/convax-package-v1.schema.json`: allow the exact `convax.plugin/5` + `convax.plugin-capability/1` source compatibility pair. +- `schemas/convax-registry-v1.schema.json`: validate the same v5 pair and embedded v5 manifests. +- `tooling/lib.mjs`: parse v5 manifests, pet declarations, package asset paths, and compatibility. +- `tooling/plugin-v5.test.js`: contract, backward-compatibility, and malicious-input tests. +- `tooling/registry.test.js`: Registry and real package coverage. +- `packages/plugins/convax-pet/package.json`: inert Plugin workspace scripts. +- `packages/plugins/convax-pet/convax-package.json`: catalog metadata. +- `packages/plugins/convax-pet/package/manifest.json`: Violet pet contribution. +- `packages/plugins/convax-pet/package/assets/violet.webp`: original 8x9 sprite atlas. +- `packages/plugins/convax-pet/package/LICENSE`: package license. +- `packages/plugins/convax-pet/package/README.md`: asset origin and atlas documentation. +- `registry/config.json`: catalog sequence bump. +- `docs/plugin-authoring.md`, `docs/packaging.md`, `docs/registry-spec.md`: v5 and pet authoring contract. + +### `/Users/bytedance/src/convax` + +- `packages/agent-runtime/src/contracts.ts`: content-free activity projection types. +- `packages/agent-runtime/src/activity.ts`: deterministic state projection and priority. +- `packages/agent-runtime/src/index.ts`: public activity exports. +- `packages/agent-runtime/test/activity.test.ts`: mapping, priority, privacy, and cancellation tests. +- `packages/desktop/src/plugin-contracts.ts`: `WebPluginPetContribution` and strict v5 parser. +- `packages/desktop/src/plugin-contracts.test.ts`: pet manifest tests that preserve existing v5 behavior. +- `packages/desktop/src/main/pet-asset-inspector.ts`: signature, decode, size, dimensions, and alpha validation. +- `packages/desktop/src/main/plugin-manager.ts`: validate pet resources inside atomic Plugin publication. +- `packages/desktop/src/main/plugin-manager.test.ts`: invalid/missing/tampered pet asset tests. +- `packages/desktop/src/pet-contracts.ts`: renderer-safe snapshots, commands, IPC names, and clients. +- `packages/desktop/src/main/agent-activity-controller.ts`: all-project aggregation, revisions, polling, priority, and read watermarks. +- `packages/desktop/src/main/agent-activity-controller.test.ts`: fake-clock/controller tests. +- `packages/desktop/src/main/pet-state-store.ts`: atomic versioned local state and custom asset records. +- `packages/desktop/src/main/pet-state-store.test.ts`: corruption, clamping input, and bounded watermark tests. +- `packages/desktop/src/main/pet-controller.ts`: pet inventory, custom import, wake/tuck, selection, activity projection, and lifecycle. +- `packages/desktop/src/main/pet-controller.test.ts`: selection/import/update/uninstall/crash behavior. +- `packages/desktop/src/main/pet-window.ts`: hardened BrowserWindow and display positioning. +- `packages/desktop/src/main/pet-window.test.ts`: window flags, navigation blocking, drag clamping, and recovery. +- `packages/desktop/src/main/pet-ipc.ts`: trusted settings and pet-renderer IPC registration. +- `packages/desktop/src/main/pet-ipc.test.ts`: sender/opaque-ID validation. +- `packages/desktop/src/preload/index.ts`: expose the host-rendered settings client. +- `packages/desktop/src/preload/pet.ts`: expose only snapshot, tray, navigation, and drag operations. +- `packages/desktop/src/renderer/env.d.ts`: type both preload surfaces. +- `packages/desktop/src/renderer/pet-settings.tsx`: Convax-styled pet settings surface. +- `packages/desktop/src/renderer/pet-settings.test.tsx`: settings behavior and accessibility. +- `packages/desktop/src/renderer/settings-view.tsx`: add the Pets navigation section. +- `packages/desktop/src/renderer/settings-view.test.tsx`: Pets section integration. +- `packages/desktop/src/renderer/agent-panel.tsx`: host-directed session selection method. +- `packages/desktop/src/renderer/index.tsx`: resolve a trusted navigation event into project/session UI and mark it seen. +- `packages/desktop/src/renderer/pet/index.html`: isolated pet renderer document. +- `packages/desktop/src/renderer/pet/index.tsx`: sprite animation and activity tray. +- `packages/desktop/src/renderer/pet/styles.css`: transparent compact/expanded presentation using Convax tokens. +- `packages/desktop/src/renderer/pet/pet-view.test.tsx`: animation, reduced motion, focus, and interaction tests. +- `packages/desktop/electron.vite.config.ts`: build the pet preload and second renderer entry. +- `packages/desktop/src/main/index.ts`: compose controllers, retain the main-window identity, and register lifecycle cleanup. +- `packages/desktop/src/main/application-lifecycle.test.ts`: main window remains distinct from the pet window. +- `packages/desktop/src/renderer/app-language.ts`: English and Simplified Chinese pet strings. +- `scripts/desktop-open-project-built-smoke.ts`: pet navigation and no-focus-steal smoke assertions. + +## Task 1: Publish the existing v5 contract and add the pet declaration + +**Repository:** `/Users/bytedance/src/convax-plugins` + +**Files:** + +- Create: `schemas/convax-plugin-manifest-v5.schema.json` +- Create: `tooling/plugin-v5.test.js` +- Modify: `schemas/convax-package-v1.schema.json` +- Modify: `schemas/convax-registry-v1.schema.json` +- Modify: `tooling/lib.mjs` + +- [ ] **Step 1: Write failing v5 contract tests** + +Add tests which prove both the pre-existing v5 contract and the new pet-only form: + +```js +const pet = { + schema: "convax.plugin/5", + id: "convax-pet", + name: "Convax Pet", + description: "Adds Violet as a desktop companion.", + version: "0.1.0", + capabilities: [], + contributes: { + pet: { + name: "Violet", + description: "A pixel companion for Convax.", + spritesheet: "assets/violet.webp", + spriteVersion: 2, + alt: "Violet, the Convax pixel companion", + }, + }, +} + +test("parses an inert v5 pet as a real capability", () => { + expect(parsePluginManifest(pet).contributes.pet).toEqual(pet.contributes.pet) +}) + +test("retains transport-neutral v5 project and LLM declarations", () => { + expect(parsePluginManifest(existingV5ProjectManifest()).schema).toBe("convax.plugin/5") + expect(parsePluginManifest(existingV5LlmManifest()).contributes.llm.provider.id).toBe("example") +}) + +test.each([ + ["remote URL", { spritesheet: "https://example.invalid/pet.webp" }], + ["traversal", { spritesheet: "../pet.webp" }], + ["unknown key", { mood: "happy" }], + ["wrong version", { spriteVersion: 3 }], +])("rejects %s pet declarations", (_label, override) => { + expect(() => parsePluginManifest({ + ...pet, + contributes: { pet: { ...pet.contributes.pet, ...override } }, + })).toThrow() +}) +``` + +- [ ] **Step 2: Run the test and confirm the red state** + +Run: + +```sh +bun test tooling/plugin-v5.test.js +``` + +Expected: FAIL because v5 is unsupported by the public parser and schema. + +- [ ] **Step 3: Add strict v5 parsing without cloning v4 semantics incorrectly** + +Add a reusable parser: + +```js +function parsePetV5(value, label) { + exactKeys(value, ["alt", "description", "name", "spritesheet", "spriteVersion"], + ["alt", "description", "name", "spritesheet", "spriteVersion"], label) + const spritesheet = parseRelativePath(value.spritesheet, `${label} spritesheet`) + if (!/\.(?:png|webp)$/.test(spritesheet)) error(label, "spritesheet must be a PNG or WebP file") + if (value.spriteVersion !== 2) error(label, "spriteVersion must equal 2") + return { + alt: cleanString(value.alt, `${label} alt`, 500), + description: cleanString(value.description, `${label} description`, 2_000), + name: cleanString(value.name, `${label} name`, 120), + spritesheet, + spriteVersion: 2, + } +} +``` + +Implement `parsePluginManifestV5` from the actual Convax v5 rules: project-wide capabilities, optional `llm`, owned Skills, existing executable declarations, and `pet`. Count `pet` as a valid non-executable Plugin capability. Do not allow `pet` in v1-v4. + +- [ ] **Step 4: Add the exact v5 JSON Schema and compatibility pair** + +The compatibility value must be: + +```json +{ + "pluginSchema": "convax.plugin/5", + "pluginHost": "convax.plugin-capability/1" +} +``` + +The v5 `pet` schema is: + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "spritesheet", "spriteVersion", "alt"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "spritesheet": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\]+\\.(?:png|webp)$", "maxLength": 1024 }, + "spriteVersion": { "const": 2 }, + "alt": { "type": "string", "minLength": 1, "maxLength": 500 } + } +} +``` + +- [ ] **Step 5: Run focused and schema tests** + +Run: + +```sh +bun test tooling/plugin-v5.test.js tooling/plugin-v4.test.js tooling/registry.test.js +bun run validate +``` + +Expected: PASS, with old v1-v4 fixtures unchanged. + +- [ ] **Step 6: Commit the public contract** + +```sh +git add schemas tooling/lib.mjs tooling/plugin-v5.test.js +git commit -m "feat(plugin): add v5 pet contribution contract" +``` + +## Task 2: Add host-side manifest and package asset validation + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Modify: `packages/desktop/src/plugin-contracts.ts` +- Modify: `packages/desktop/src/plugin-contracts.test.ts` +- Create: `packages/desktop/src/main/pet-asset-inspector.ts` +- Create: `packages/desktop/src/main/pet-asset-inspector.test.ts` +- Modify: `packages/desktop/src/main/plugin-manager.ts` +- Modify: `packages/desktop/src/main/plugin-manager.test.ts` + +- [ ] **Step 1: Write failing manifest tests** + +```ts +const petManifest = { + capabilities: [], + contributes: { + pet: { + alt: "Violet, the Convax pixel companion", + description: "A pixel companion for Convax.", + name: "Violet", + spritesheet: "assets/violet.webp", + spriteVersion: 2, + }, + }, + description: "Adds Violet as a desktop companion.", + id: "convax-pet", + name: "Convax Pet", + schema: "convax.plugin/5", + version: "0.1.0", +} + +expect(parseWebPluginManifest(petManifest).contributes.pet).toEqual(petManifest.contributes.pet) +expect(() => parseWebPluginManifest({ ...petManifest, schema: "convax.plugin/4" })).toThrow("unsupported field") +``` + +- [ ] **Step 2: Run the parser test and confirm failure** + +```sh +bun test packages/desktop/src/plugin-contracts.test.ts +``` + +Expected: FAIL with `Plugin contributions contains an unsupported field: pet`. + +- [ ] **Step 3: Add typed parsing** + +```ts +export interface WebPluginPetContribution { + alt: string + description: string + name: string + spritesheet: string + spriteVersion: 2 +} + +function parsePet(value: unknown): WebPluginPetContribution { + const input = asRecord(value, "Pet contribution") + assertKeys(input, ["alt", "description", "name", "spritesheet", "spriteVersion"], "Pet contribution") + const spritesheet = requireWebPluginRelativePath(input.spritesheet, "Pet spritesheet") + if (!/\.(?:png|webp)$/.test(spritesheet)) throw new Error("Pet spritesheet must be a PNG or WebP file") + if (input.spriteVersion !== 2) throw new Error("Pet spriteVersion must equal 2") + return { + alt: requireString(input.alt, "Pet alt", 500), + description: requireString(input.description, "Pet description", 2_000), + name: requireString(input.name, "Pet name", 120), + spritesheet, + spriteVersion: 2, + } +} +``` + +Include `pet` only in the v5 contribution key list and in the v5 capability-presence check. + +- [ ] **Step 4: Write failing asset inspector and atomic-install tests** + +Test exact requirements and injection at the transaction boundary: + +```ts +expect(await inspectPetAsset(validWebp)).toMatchObject({ + format: "webp", + hasTransparency: true, + height: 1872, + width: 1536, +}) +await expect(manager.installBundle(bundleWithMissingPet())).rejects.toThrow("Pet spritesheet does not exist") +await expect(manager.installBundle(bundleWithWrongSize())).rejects.toThrow("1536 by 1872") +expect(await manager.list()).toEqual([]) +``` + +- [ ] **Step 5: Implement Electron-backed decoded inspection behind an injected port** + +```ts +export interface PetAssetInspection { + format: "png" | "webp" + hasTransparency: boolean + height: number + width: number +} + +export interface PetAssetInspector { + inspect(path: string): Promise +} + +export function createElectronPetAssetInspector(nativeImage: typeof import("electron").nativeImage): PetAssetInspector { + return { + async inspect(path) { + const image = nativeImage.createFromPath(path) + if (image.isEmpty()) throw new Error("Pet spritesheet could not be decoded") + const { width, height } = image.getSize() + const bitmap = image.toBitmap({ scaleFactor: 1 }) + let hasTransparency = false + for (let offset = 3; offset < bitmap.length; offset += 4) { + if (bitmap[offset] < 255) { hasTransparency = true; break } + } + return { format: path.toLowerCase().endsWith(".png") ? "png" : "webp", hasTransparency, height, width } + }, + } +} +``` + +Validate magic bytes before decode, enforce at most 20 MiB for custom imports, and enforce 1536×1872 plus transparency. `WebPluginManager` receives the inspector as an optional constructor dependency and validates the declared resource during staging before publication. + +- [ ] **Step 6: Run tests** + +```sh +bun test packages/desktop/src/plugin-contracts.test.ts packages/desktop/src/main/pet-asset-inspector.test.ts packages/desktop/src/main/plugin-manager.test.ts +``` + +Expected: PASS and existing publication rollback tests remain green. + +- [ ] **Step 7: Commit** + +```sh +git add packages/desktop/src/plugin-contracts.ts packages/desktop/src/plugin-contracts.test.ts packages/desktop/src/main/pet-asset-inspector.ts packages/desktop/src/main/pet-asset-inspector.test.ts packages/desktop/src/main/plugin-manager.ts packages/desktop/src/main/plugin-manager.test.ts +git commit -m "feat(desktop): validate declarative pet assets" +``` + +## Task 3: Add content-free agent activity projection + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/agent-runtime/src/activity.ts` +- Create: `packages/agent-runtime/test/activity.test.ts` +- Modify: `packages/agent-runtime/src/contracts.ts` +- Modify: `packages/agent-runtime/src/index.ts` + +- [ ] **Step 1: Write failing pure-function tests** + +```ts +expect(projectAgentActivity(state({ pendingQuestions: [question] }))).toMatchObject({ state: "needs-input", input: "question" }) +expect(projectAgentActivity(state({ pendingPermissions: [permission] }))).toMatchObject({ state: "needs-input", input: "permission" }) +expect(projectAgentActivity(state({ status: { type: "retry", attempt: 2, message: "secret", next: 1 } }))).toEqual({ state: "running" }) +expect(projectAgentActivity(failedState("secret error"))).toEqual({ state: "blocked" }) +expect(JSON.stringify(projectAgentActivity(failedState("secret error")))).not.toContain("secret") +expect(compareAgentActivity({ state: "needs-input" }, { state: "blocked" })).toBeLessThan(0) +``` + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/agent-runtime/test/activity.test.ts +``` + +Expected: FAIL because `activity.ts` does not exist. + +- [ ] **Step 3: Implement the minimal public projection** + +```ts +export type AgentActivityState = + | { state: "needs-input"; input: "permission" | "question" } + | { state: "blocked" } + | { state: "ready" } + | { state: "running" } + | { state: "idle" } + +export const agentActivityPriority = { + "needs-input": 0, + blocked: 1, + ready: 2, + running: 3, + idle: 4, +} as const +``` + +`projectAgentActivity` reads status, pending arrays, and only the terminal presence/error flags of the latest assistant message. It never returns message parts, errors, retry text, permission metadata, or question text. Accept a `{ canceled?: boolean; seenAfter?: number }` context so user cancellation maps to idle and successful unseen completion maps to ready. + +- [ ] **Step 4: Run package tests and typecheck** + +```sh +bun --cwd packages/agent-runtime test +bun --cwd packages/agent-runtime typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add packages/agent-runtime +git commit -m "feat(agent-runtime): project session activity safely" +``` + +## Task 4: Aggregate activity across every project + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/pet-contracts.ts` +- Create: `packages/desktop/src/main/agent-activity-controller.ts` +- Create: `packages/desktop/src/main/agent-activity-controller.test.ts` +- Modify: `packages/desktop/src/main/agent-ipc.ts` +- Modify: `packages/desktop/src/main/open-project-ipc.test.ts` + +- [ ] **Step 1: Write controller tests with fake projects/runtime/clock** + +```ts +const controller = new AgentActivityController({ clock, projects, runtime, pollMs: 700 }) +await controller.start() +expect(controller.getSnapshot().activities.map(({ projectId, state }) => [projectId, state])).toEqual([ + ["project-b", "needs-input"], + ["project-a", "running"], +]) +expect(controller.getSnapshot()).not.toHaveProperty("messages") +expect(JSON.stringify(controller.getSnapshot())).not.toContain("/private/project") +``` + +Cover startup recovery, same-priority recency, stale revision rejection, missing projects, runtime backoff, a bounded activity count, mark-seen, retry, and cancellation. + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/agent-activity-controller.test.ts +``` + +Expected: FAIL because the controller does not exist. + +- [ ] **Step 3: Define the renderer-safe contract** + +```ts +export interface PetActivitySummary { + id: string + input?: "permission" | "question" + projectId: string + projectName: string + sessionId: string + sessionName: string + state: "needs-input" | "blocked" | "ready" | "running" + updatedAt: number +} + +export interface PetActivitySnapshot { + activities: PetActivitySummary[] + revision: number +} +``` + +Create opaque IDs with `randomUUID()` and retain their project/session mapping only in main memory. + +- [ ] **Step 4: Implement aggregation and mutation hooks** + +`start()` enumerates `projects.list()` and runtime sessions, skips missing projects, and polls only active or recoverable sessions. Add `promptStarted`, `promptSettled`, `aborted`, `permissionReplied`, `questionReplied`, `projectChanged`, and `markSeen`. Every accepted mutation increments one monotonic revision. Cap retained activities and watermarks at 256 each. + +Wrap the existing `registerAgentIpc` operations: + +```ts +activity.promptStarted(input.scopeId, input.sessionId) +try { + const result = await runtime.prompt(runtimeInput) + await activity.promptSettled(input.scopeId, input.sessionId) + return result +} catch (error) { + await activity.promptSettled(input.scopeId, input.sessionId, { failed: true }) + throw error +} +``` + +- [ ] **Step 5: Run focused desktop tests** + +```sh +bun test packages/desktop/src/main/agent-activity-controller.test.ts packages/desktop/src/main/open-project-ipc.test.ts +bun --cwd packages/desktop typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add packages/desktop/src/pet-contracts.ts packages/desktop/src/main/agent-activity-controller.ts packages/desktop/src/main/agent-activity-controller.test.ts packages/desktop/src/main/agent-ipc.ts packages/desktop/src/main/open-project-ipc.test.ts +git commit -m "feat(desktop): aggregate agent activity across projects" +``` + +## Task 5: Persist pet selection, position, and read watermarks + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/main/pet-state-store.ts` +- Create: `packages/desktop/src/main/pet-state-store.test.ts` + +- [ ] **Step 1: Write failing persistence tests** + +```ts +await store.write({ awake: true, positions: { displayA: { x: 40, y: 60 } }, selected: { kind: "plugin", pluginId: "convax-pet" }, seen: {} }) +expect(await store.read()).toMatchObject({ awake: true, schema: "convax.pet-state/1" }) +await writeFile(file, "{broken") +expect(await store.read()).toEqual(defaultPetState) +expect(Object.keys(boundState({ seen: hugeSeen }).seen)).toHaveLength(256) +``` + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/pet-state-store.test.ts +``` + +- [ ] **Step 3: Implement strict versioned atomic storage** + +Write mode `0600` to a sibling UUID temporary file, `fsync`, rename, then best-effort directory `fsync`. Parse exact keys and finite safe coordinates. The state contains only selection, awake state, per-display positions, and bounded timestamps. + +```ts +export interface PetPersistedState { + awake: boolean + positions: Record + schema: "convax.pet-state/1" + seen: Record + selected?: { kind: "plugin"; pluginId: string } | { id: string; kind: "custom" } +} +``` + +- [ ] **Step 4: Run tests and commit** + +```sh +bun test packages/desktop/src/main/pet-state-store.test.ts +git add packages/desktop/src/main/pet-state-store.ts packages/desktop/src/main/pet-state-store.test.ts +git commit -m "feat(desktop): persist local pet state" +``` + +## Task 6: Build the generic pet controller and custom import flow + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/main/pet-controller.ts` +- Create: `packages/desktop/src/main/pet-controller.test.ts` +- Modify: `packages/desktop/src/pet-contracts.ts` + +- [ ] **Step 1: Write failing inventory/lifecycle/import tests** + +```ts +expect((await controller.listPets()).pets).toEqual([ + expect.objectContaining({ id: "plugin:convax-pet", name: "Violet", source: "plugin" }), +]) +await controller.select("plugin:convax-pet") +expect(window.open).not.toHaveBeenCalled() +await controller.setAwake(true) +expect(window.open).toHaveBeenCalledTimes(1) +await controller.beforePluginChange("convax-pet") +expect(window.close).toHaveBeenCalled() +await expect(controller.importCustom(invalidPath)).rejects.toThrow("1536 by 1872") +expect(await readdir(staging)).toEqual([]) +``` + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/pet-controller.test.ts +``` + +- [ ] **Step 3: Implement host-owned inventory and selection** + +Inventory maps any installed manifest with `contributes.pet`; no branch may compare `plugin.id` with `convax-pet`. Resolve the asset only with `pluginManager.resolveAsset(plugin.id, pet.spritesheet)`. Selecting does not wake. Uninstalling the selected source closes the window and clears selection. + +- [ ] **Step 4: Implement staged custom import** + +Copy the selected PNG/WebP into `userData/pets/.staging-`, run the same inspector, generate an opaque custom ID, and atomically rename to `userData/pets//spritesheet.`. Store a host-authored metadata JSON; never store or expose the original path. + +- [ ] **Step 5: Run tests and commit** + +```sh +bun test packages/desktop/src/main/pet-controller.test.ts packages/desktop/src/main/pet-state-store.test.ts +git add packages/desktop/src/pet-contracts.ts packages/desktop/src/main/pet-controller.ts packages/desktop/src/main/pet-controller.test.ts +git commit -m "feat(desktop): manage installed and custom pets" +``` + +## Task 7: Create the hardened floating pet window + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/main/pet-window.ts` +- Create: `packages/desktop/src/main/pet-window.test.ts` +- Modify: `packages/desktop/src/main/index.ts` +- Modify: `packages/desktop/src/main/application-lifecycle.test.ts` + +- [ ] **Step 1: Write failing BrowserWindow and display tests** + +```ts +expect(created.options).toMatchObject({ + alwaysOnTop: true, + frame: false, + height: 176, + show: false, + skipTaskbar: true, + transparent: true, + width: 176, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, +}) +expect(created.windowOpenHandler()).toEqual({ action: "deny" }) +expect(clampPetBounds({ x: 5000, y: -20 }, display.workArea, { width: 176, height: 176 })).toEqual(expected) +``` + +Also prove that an existing pet window does not prevent `app.activate` from recreating a missing main window. + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/pet-window.test.ts packages/desktop/src/main/application-lifecycle.test.ts +``` + +- [ ] **Step 3: Implement the window boundary** + +Use a dedicated pet preload, fixed local renderer URL, `setWindowOpenHandler(() => ({action: "deny"}))`, prevent all non-exact main-frame navigation, deny permissions/downloads, and never add pet webContents to the main renderer trust set. `showInactive()` avoids focus stealing. + +Retain `mainWindow` explicitly in `main/index.ts`; replace `getAllWindows()[0]` and `getAllWindows().length` assumptions used by second-instance and activate paths. + +- [ ] **Step 4: Add crash and display lifecycle** + +On `render-process-gone`, recreate once per wake generation; a second crash calls `setAwake(false)`. Re-clamp on `display-removed`, `display-metrics-changed`, and system resume. Persist position only after a completed drag. + +- [ ] **Step 5: Run tests and commit** + +```sh +bun test packages/desktop/src/main/pet-window.test.ts packages/desktop/src/main/application-lifecycle.test.ts +git add packages/desktop/src/main/pet-window.ts packages/desktop/src/main/pet-window.test.ts packages/desktop/src/main/index.ts packages/desktop/src/main/application-lifecycle.test.ts +git commit -m "feat(desktop): add secure floating pet window" +``` + +## Task 8: Add minimal pet IPC and trusted navigation + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/main/pet-ipc.ts` +- Create: `packages/desktop/src/main/pet-ipc.test.ts` +- Create: `packages/desktop/src/preload/pet.ts` +- Modify: `packages/desktop/src/preload/index.ts` +- Modify: `packages/desktop/src/renderer/env.d.ts` +- Modify: `packages/desktop/src/renderer/agent-panel.tsx` +- Modify: `packages/desktop/src/renderer/index.tsx` + +- [ ] **Step 1: Write failing sender and opaque navigation tests** + +```ts +await expect(invokeAsUntrusted("pet:list")).rejects.toThrow("untrusted renderer") +await expect(invokeAsPet("pet:navigate", { activityId: "unknown" })).rejects.toThrow("no longer available") +await invokeAsPet("pet:navigate", { activityId }) +expect(mainWindow.webContents.send).toHaveBeenCalledWith("pet:navigate", { activityId, projectId, sessionId }) +``` + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/pet-ipc.test.ts +``` + +- [ ] **Step 3: Define two capability-minimal clients** + +The main settings preload exposes list/select/wake/tuck/import/delete and change subscription. The pet preload exposes only current snapshot subscription, tray expansion, opaque navigation, and bounded drag deltas. Neither exposes a path, arbitrary channel, or agent API. + +```ts +export interface PetOverlayClient { + drag(input: { dx: number; dy: number; phase: "move" | "end" }): void + navigate(input: { activityId: string }): Promise + onSnapshot(listener: (snapshot: PetOverlaySnapshot) => void): () => void + setExpanded(input: { expanded: boolean }): Promise +} +``` + +- [ ] **Step 4: Add host-directed session selection** + +Extend the handle without exposing it outside the trusted renderer: + +```ts +export interface AgentPanelHandle { + addResources(resources: readonly AgentResource[]): void + openSession(sessionId: string): Promise +} +``` + +In `renderer/index.tsx`, subscribe to `window.convax.pets.onNavigate`, call `await projectController.activate(projectId)`, open the secondary panel, call `agentPanelRef.current?.openSession(sessionId)`, then acknowledge `markDisplayed(activityId)`. Merely opening the pet tray must not mark read. + +- [ ] **Step 5: Run tests and commit** + +```sh +bun test packages/desktop/src/main/pet-ipc.test.ts packages/desktop/src/renderer/agent-panel.test.tsx +bun --cwd packages/desktop typecheck +git add packages/desktop/src/main/pet-ipc.ts packages/desktop/src/main/pet-ipc.test.ts packages/desktop/src/preload packages/desktop/src/renderer/env.d.ts packages/desktop/src/renderer/agent-panel.tsx packages/desktop/src/renderer/index.tsx +git commit -m "feat(desktop): connect pet activity navigation" +``` + +## Task 9: Build and test the isolated pet renderer + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/renderer/pet/index.html` +- Create: `packages/desktop/src/renderer/pet/index.tsx` +- Create: `packages/desktop/src/renderer/pet/styles.css` +- Create: `packages/desktop/src/renderer/pet/pet-view.tsx` +- Create: `packages/desktop/src/renderer/pet/pet-view.test.tsx` +- Modify: `packages/desktop/electron.vite.config.ts` + +- [ ] **Step 1: Write failing animation and interaction tests** + +```tsx +expect(frameFor({ animation: "idle", elapsed: 0 })).toEqual({ column: 0, row: 0 }) +expect(frameFor({ animation: "review", elapsed: 750 }).row).toBe(8) +expect(renderPet({ reducedMotion: true })).not.toContain("animation-timer") +expect(renderPet({ state: "needs-input" })).toContain("Needs input") +expect(renderPet({ state: "blocked" })).toContain("Blocked") +``` + +Test the 4-pixel drag threshold, jump-before-navigation, maximum four visible rows, keyboard activation, Escape dismissal, and status text independent of color. + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/renderer/pet/pet-view.test.tsx +``` + +- [ ] **Step 3: Implement deterministic atlas animation** + +```ts +export const petAnimations = { + idle: { row: 0, durations: [280, 110, 110, 140, 140, 320] }, + "running-right": { row: 1, durations: [120, 120, 120, 120, 120, 120, 120, 220] }, + "running-left": { row: 2, durations: [120, 120, 120, 120, 120, 120, 120, 220] }, + waving: { row: 3, durations: [140, 140, 140, 280] }, + jumping: { row: 4, durations: [140, 140, 140, 140, 280] }, + failed: { row: 5, durations: [140, 140, 140, 140, 140, 140, 140, 240] }, + waiting: { row: 6, durations: [150, 150, 150, 150, 150, 260] }, + running: { row: 7, durations: [120, 120, 120, 120, 120, 220] }, + review: { row: 8, durations: [150, 150, 150, 150, 150, 280] }, +} as const +``` + +Use `background-size: 800% 900%`, pixelated rendering, a 96×104 pet, a 176×176 collapsed surface, and a 356×320 expanded surface. Urgent states bypass minimum dwell; ordinary state changes wait for the current loop boundary. + +- [ ] **Step 4: Add second renderer/preload build entries** + +```ts +preload: { build: { rollupOptions: { input: { index: "src/preload/index.ts", pet: "src/preload/pet.ts" } } } }, +renderer: { build: { rollupOptions: { input: { index: "src/renderer/index.html", pet: "src/renderer/pet/index.html" } } } }, +``` + +- [ ] **Step 5: Run renderer tests/build and commit** + +```sh +bun test packages/desktop/src/renderer/pet/pet-view.test.tsx +bun --cwd packages/desktop build +git add packages/desktop/src/renderer/pet packages/desktop/electron.vite.config.ts +git commit -m "feat(desktop): render animated pet activity" +``` + +## Task 10: Add Convax-styled pet settings + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Create: `packages/desktop/src/renderer/pet-settings.tsx` +- Create: `packages/desktop/src/renderer/pet-settings.test.tsx` +- Modify: `packages/desktop/src/renderer/settings-view.tsx` +- Modify: `packages/desktop/src/renderer/settings-view.test.tsx` +- Modify: `packages/desktop/src/renderer/app-language.ts` + +- [ ] **Step 1: Write failing UI tests** + +```tsx +expect(markup).toContain("Pets") +expect(markup).toContain("Violet") +expect(markup).toContain("Wake pet") +expect(markup).not.toContain("/Users/") +``` + +Interaction tests cover selection without wake, explicit wake/tuck, import cancellation, invalid-import error, and deletion confirmation for custom pets. + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/renderer/pet-settings.test.tsx packages/desktop/src/renderer/settings-view.test.tsx +``` + +- [ ] **Step 3: Add the Pets section with existing primitives** + +Extend `SettingsSection` to include `pets`. Use `Button`, existing rounded card/border styles, `#7657e8` through the primary token, muted text, and existing localization helpers. Do not build a Plugin iframe or special-case Violet's package ID. + +- [ ] **Step 4: Run UI tests and commit** + +```sh +bun test packages/desktop/src/renderer/pet-settings.test.tsx packages/desktop/src/renderer/settings-view.test.tsx packages/desktop/src/renderer/application-menu.test.tsx +git add packages/desktop/src/renderer/pet-settings.tsx packages/desktop/src/renderer/pet-settings.test.tsx packages/desktop/src/renderer/settings-view.tsx packages/desktop/src/renderer/settings-view.test.tsx packages/desktop/src/renderer/app-language.ts +git commit -m "feat(desktop): add pet preferences" +``` + +## Task 11: Compose pet lifecycle into the desktop application + +**Repository:** `/Users/bytedance/src/convax` + +**Files:** + +- Modify: `packages/desktop/src/main/index.ts` +- Modify: `packages/desktop/src/main/plugin-management-ipc.test.ts` +- Modify: `packages/desktop/src/main/application-lifecycle.ts` +- Modify: `packages/desktop/src/main/application-lifecycle.test.ts` + +- [ ] **Step 1: Write failing composition tests** + +Prove that startup reconciles the Plugin manager before selecting a pet, plugin `beforeChange` closes an active asset, `onDidChange` re-resolves the contribution, and will-quit disposes activity polling/window IPC before runtime disposal. + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test packages/desktop/src/main/plugin-management-ipc.test.ts packages/desktop/src/main/application-lifecycle.test.ts +``` + +- [ ] **Step 3: Compose dependencies in ownership order** + +```ts +const petAssetInspector = createElectronPetAssetInspector(nativeImage) +const pluginManager = new WebPluginManager(pluginRoot, {}, builtinIds, { petAssetInspector }) +const activity = new AgentActivityController({ projects: projectManager, runtime: agentRuntime }) +const pets = new PetController({ activity, pluginManager, stateStore, window: petWindow }) +``` + +Compose existing Plugin lifecycle callbacks with `pets.beforePluginChange` and `pets.pluginChanged`; do not replace Skill, generation, service, or provider cleanup. Start activity after project/runtime construction and stop it during will-quit. + +- [ ] **Step 4: Run main-process tests and commit** + +```sh +bun test packages/desktop/src/main +bun --cwd packages/desktop typecheck +git add packages/desktop/src/main +git commit -m "feat(desktop): compose pet application lifecycle" +``` + +## Task 12: Create and publish the Violet Plugin package + +**Repository:** `/Users/bytedance/src/convax-plugins` + +**Files:** + +- Create: `packages/plugins/convax-pet/package.json` +- Create: `packages/plugins/convax-pet/convax-package.json` +- Create: `packages/plugins/convax-pet/package/manifest.json` +- Create: `packages/plugins/convax-pet/package/LICENSE` +- Create: `packages/plugins/convax-pet/package/README.md` +- Create: `packages/plugins/convax-pet/package/assets/violet.webp` +- Modify: `registry/config.json` +- Modify: `tooling/registry.test.js` + +- [ ] **Step 1: Add failing real-package Registry expectations** + +```js +const violet = packages.find((item) => item.metadata.id === "convax-pet") +expect(violet.manifest.contributes.pet).toEqual({ + alt: "Violet, the Convax pixel companion", + description: "A pixel companion for Convax.", + name: "Violet", + spritesheet: "assets/violet.webp", + spriteVersion: 2, +}) +expect(violet.manifest).not.toHaveProperty("entry") +expect(violet.manifest).not.toHaveProperty("runtime") +``` + +- [ ] **Step 2: Run and confirm failure** + +```sh +bun test tooling/registry.test.js +``` + +- [ ] **Step 3: Generate Violet using the imagegen skill** + +Use `imagegen` with this art direction, then inspect the result before adopting it: + +```text +Create an original purple pixel-art desktop companion named Violet. Transparent background. +Deliver a clean 8-column by 9-row animation atlas with consistent 192x208 cells: idle, +run right, run left, wave, jump, failed/sad, waiting, working, review/needs-input. +Keep one stable character silhouette, restrained lavender highlights, expressive face and +hands, crisp hard pixel edges, no text, logos, borders, guides, shadows outside the figure, +or resemblance to existing Codex pet characters. +``` + +Mechanically place/crop validated frames into a 1536×1872 WebP only after visually checking every row. Keep unused cells transparent and the final compressed asset below the repository's 2 MiB per-file limit. + +- [ ] **Step 4: Add the inert package** + +`convax-package.json` uses: + +```json +{ + "schema": "convax.package/1", + "kind": "plugin", + "id": "convax-pet", + "name": "Convax Pet", + "description": "Adds Violet as a local desktop companion for Convax activity.", + "version": "0.1.0", + "license": "MIT", + "compatibility": { + "pluginSchema": "convax.plugin/5", + "pluginHost": "convax.plugin-capability/1" + }, + "yanked": false +} +``` + +The package `manifest.json` matches Task 1's pet fixture. The workspace has only `validate` and `pack` scripts. Document the image-generation provenance and manual atlas adjustments without claiming third-party ownership. + +- [ ] **Step 5: Bump Registry sequence and run package checks** + +Change `registry/config.json` sequence from 22 to 23, then run: + +```sh +bun run validate -- --kind plugin --id convax-pet +bun run pack -- --kind plugin --id convax-pet +bun test tooling/plugin-v5.test.js tooling/registry.test.js +``` + +Expected: the Plugin validates and packs with no executable or dependency content. + +- [ ] **Step 6: Commit** + +```sh +git add packages/plugins/convax-pet registry/config.json tooling/registry.test.js bun.lock +git commit -m "feat(plugin): add Violet Convax pet" +``` + +## Task 13: Document the public v5 pet contract + +**Repository:** `/Users/bytedance/src/convax-plugins` + +**Files:** + +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/plugin-authoring.md` +- Modify: `docs/packaging.md` +- Modify: `docs/registry-spec.md` + +- [ ] **Step 1: Add documentation checks to existing tests where applicable** + +Extend Registry/workspace tests so the documented schema file exists and the package README references `contributes.pet`, `spriteVersion: 2`, 1536×1872, and the inert ZIP rule. + +- [ ] **Step 2: Update documentation** + +Document: + +```json +{ + "contributes": { + "pet": { + "name": "Violet", + "description": "A pixel companion for Convax.", + "spritesheet": "assets/violet.webp", + "spriteVersion": 2, + "alt": "Violet, the Convax pixel companion" + } + } +} +``` + +State explicitly that pet Plugins are inert, do not receive a host port, cannot create windows, and use the existing v5 compatibility pair. + +- [ ] **Step 3: Run docs-adjacent validation and commit** + +```sh +bun run validate +bun test tooling/workspaces.test.js tooling/registry.test.js +git add README.md README.zh-CN.md docs +git commit -m "docs(plugin): document pet contributions" +``` + +## Task 14: Complete cross-repository verification and smoke testing + +**Repositories:** Both + +- [ ] **Step 1: Verify `convax-plugins` from a frozen install** + +```sh +bun install --frozen-lockfile --ignore-scripts +bun run workspaces:build:packages +bun run validate +bun run workspaces:typecheck +bun run workspaces:test +bun run build:companions +bun test +bun run pack +bun run build:index +git diff --check +``` + +Expected: every command exits 0; generated `dist/` remains uncommitted. + +- [ ] **Step 2: Verify Convax packages and boundaries** + +```sh +bun --cwd packages/agent-runtime typecheck +bun --cwd packages/agent-runtime test +bun --cwd packages/desktop typecheck +bun --cwd packages/desktop test +bun --cwd packages/desktop build +bun check +bun --cwd packages/desktop smoke:open-project +git diff --check +``` + +Expected: every command exits 0. + +- [ ] **Step 3: Run the native acceptance checklist** + +Install the locally packed Plugin through the existing management UI and verify: + +1. Installation does not wake the pet. +2. Selecting Violet and pressing Wake shows the collapsed window without focus theft. +3. Two projects produce the exact global priority order. +4. Permission/question, retry, success, failure, and cancellation map correctly. +5. Clicking the pet opens the exact project/session and only then marks it read. +6. Position survives restart and clamps after display removal/scale change. +7. Reduced motion uses a still frame and no animation timer. +8. Valid custom import succeeds; invalid dimensions, opaque images, URLs, and traversal fail. +9. Updating the selected Plugin preserves selection; uninstall closes and clears it. +10. DevTools/network inspection shows no pet-originated network request. + +- [ ] **Step 4: Inspect final branch state** + +```sh +git status --short +git log --oneline --decorate -15 +``` + +Expected: only intentional source changes/commits; no `dist`, dependencies, credentials, local state, generated indexes, or temporary art files are tracked. diff --git a/docs/superpowers/specs/2026-07-22-convax-pet-design.md b/docs/superpowers/specs/2026-07-22-convax-pet-design.md new file mode 100644 index 0000000..172b1cc --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-convax-pet-design.md @@ -0,0 +1,558 @@ +# Convax Pet Feature Plugin Design + +**Date:** 2026-07-22 +**Status:** Approved +**Plugin owner:** `packages/plugins/convax-pet` in `convax-plugins` +**Host owners:** generic pet platform support in `@convax/agent-runtime` and +`@convax/desktop` in `/Users/bytedance/src/convax` +**External tools:** None + +## 1. Summary + +Convax Pet is one installable feature Plugin that owns the complete pet product +experience. It renders the floating companion, maps Agent activity to animation, +provides the pet collection and settings UI, and ships an internal pet library +whose first member is Violet. + +Convax remains a thin trusted host. It provides only operations that a sandboxed +Web Plugin cannot safely perform: creating a native transparent overlay, projecting +content-free Agent activity across projects, resolving session navigation, serving +installed Plugin assets, and persisting bounded Plugin and window state. + +The key ownership rule is: + +```text +One Pet feature Plugin owns many pets. +Pets are packaged library data; individual pets are not separate Convax Plugins. +``` + +The current branch implementation instead makes each pet a declarative resource +Plugin and keeps most product behavior in Convax. This design supersedes that +boundary before release. + +## 2. Goals + +1. Make the complete pet experience installable, disableable, and uninstallable as + one Convax Plugin. +2. Let the Plugin own a versioned library containing Violet initially and more + bundled pets in future Plugin releases. +3. Preserve Codex-style activity semantics, direct navigation, reduced motion, + multi-display positioning, and Convax visual language. +4. Keep Electron, native paths, unrestricted filesystem access, Agent content, and + navigation authority outside the Plugin sandbox. +5. Keep host behavior generic and contribution-driven, with no special case for the + `convax-pet` package ID. + +## 3. Non-goals + +- Making Violet or any other character a separate Convax Plugin. +- Importing Goku, legacy `pet.json`, a `pets` directory, or a raw spritesheet. +- Adding a user-authored pet format or local pet editor in the first release. +- Giving Plugin code Node, Electron, arbitrary IPC, shell, network, or native path + access. +- Loading remote scripts, remote pet assets, native binaries, a companion process, + or an install hook from the Plugin ZIP. +- Exposing prompts, messages, question text, permission details, tool arguments, + project paths, or credentials to the Plugin. +- Copying private Codex or Convax implementation code or third-party character art. +- Defining a network marketplace for pets in the first release. + +## 4. Research and Current State + +### 4.1 Codex-compatible behavior and atlas + +The product reference is an optional desktop companion that reflects Running, +Needs input, Ready, and Blocked activity, persists its selection and position, +opens the relevant activity when clicked, and displays a still frame when reduced +motion is enabled. + +The sprite contract used by the bundled library is a transparent PNG or WebP, no +larger than 20 MiB, exactly 1536 by 1872 pixels. It contains eight 192 by 208 cells +across and nine animation rows: `idle`, `running-right`, `running-left`, `waving`, +`jumping`, `failed`, `waiting`, `running`, and `review`. + +References used for behavioral and file compatibility only: + +- [OpenAI Academy: Pets](https://learn.chatgpt.com/docs/pets) +- [OpenAI Codex documentation](https://developers.openai.com/codex/) +- [Public Codex pet atlas constants](https://github.com/backnotprop/codex-pets-react/blob/main/src/lib/atlas.ts) + +### 4.2 Convax Plugin constraints + +A Convax Web Plugin is inert, offline package content. Its HTML, CSS, and +JavaScript run in a sandboxed renderer without Node or Electron. Native operations +must be exposed as versioned, contribution-scoped host methods. The Plugin ZIP +must not contain an executable, server, dependency tree, native binary, or install +hook. + +The package remains independently validated, deterministically packed, and +published through the Registry. The contents of `package/` are the ZIP root. + +### 4.3 Superseded implementation + +The first implementation on `codex/convax-pet` has this shape: + +```text +Convax host + owns overlay UI, animation mapping, pet collection and preferences + ^ + | reads one contributes.pet resource + | +one Plugin per pet + owns only name, description and one atlas +``` + +It also exposes a host-owned raw spritesheet import path. That prototype does not +make the pet product a Plugin and is outside the revised scope. Both behaviors must +be refactored before publication. + +## 5. Considered Approaches + +### 5.1 Host-owned feature with one Plugin per pet + +This is the current prototype. It has a small Plugin API, but product logic is +permanently built into Convax and every pet becomes a separately installed Plugin. +It does not meet the product ownership goal. + +### 5.2 Thin host plus one feature Plugin — selected + +Convax supplies native and security-sensitive primitives. One sandboxed Pet Plugin +owns the overlay renderer, animation rules, packaged library, and settings UI. This +makes the feature genuinely installable while preserving the existing trust +boundary. + +### 5.3 Fully privileged Plugin + +Allowing a Plugin to create Electron windows, inspect sessions, and read the +filesystem directly would reduce host adapter code but would defeat the Convax +sandbox. It is rejected. + +### 5.4 Sidecar executable + +A companion process would add platform packaging, process supervision, executable +authorization, and a second IPC boundary without providing a meaningful product +benefit. It is rejected. + +## 6. Architecture + +```text +packages/plugins/convax-pet + static overlay + settings bundles + Violet atlas + pet library + activity presentation logic + | + | convax.plugin/5 contributes.pet + | convax.pet-host/1 scoped ports + v +Convax Desktop pet platform + provider lifecycle + isolated BrowserWindow + settings mount + activity projection + validated navigation + installed asset protocol + bounded state storage + | + v +@convax/agent-runtime and Electron + projects/sessions displays/windows +``` + +### 6.1 Ownership + +The Pet Plugin owns: + +- overlay HTML, CSS, JavaScript, and accessible interaction behavior; +- collapsed and expanded layouts; +- activity priority and state-to-animation mapping; +- sprite animation timing and reduced-motion presentation; +- pet collection, selection, descriptions, previews, and packaged library data; +- Violet as the initial bundled library entry; +- Plugin preferences that do not require native interpretation. + +Convax owns: + +- discovering and activating exactly one Pet contribution; +- the transparent, always-on-top, frameless BrowserWindow; +- isolated sessions, CSP, navigation blocking, crash recovery, and fixed preload; +- cross-project content-free Agent activity projection; +- resolving opaque activity IDs and opening the correct project/session; +- validated serving of immutable installed Plugin assets; +- bounded, atomic Plugin-private state storage; +- window position, display identity, scale context, wake/tuck lifecycle, and app + shutdown ordering. + +Convax does not own Violet, animation rows, activity priority, pet cards, packaged +library metadata, or pet-specific copy. + +### 6.2 Provider uniqueness + +`contributes.pet` is a singleton feature contribution. At most one enabled Plugin +may own the Pet surface at a time. Installation may coexist with another disabled +provider, but activation must ask the user to replace the active provider rather +than allowing two Plugins to compete for one native overlay. + +The rule is keyed by contribution type, never package ID. When no Pet provider is +enabled, Convax does not create the overlay, subscribe to global activity, or show +the Pet settings section. + +## 7. Plugin Contract + +### 7.1 Manifest + +The revised `convax.plugin/5` contribution declares feature entry points, not one +pet asset: + +```json +{ + "schema": "convax.plugin/5", + "id": "convax-pet", + "name": "Convax Pet", + "description": "A local desktop companion and pet library for Convax activity.", + "version": "0.2.1", + "capabilities": [ + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write" + ], + "contributes": { + "pet": { + "library": "pet-library.json", + "overlay": "pet/index.html", + "settings": "settings/index.html", + "protocol": "convax.pet-host/1" + } + } +} +``` + +`library`, `overlay`, and `settings` are package-relative, case-sensitive paths +that must resolve to regular files. Unknown fields and capabilities are rejected. +A Pet feature package remains static Web content; it may contain bundled JavaScript +but no native runtime. + +The current `name`, `description`, `spritesheet`, `spriteVersion`, and `alt` form is +removed before publication. Existing unrelated `convax.plugin/5` contributions +remain valid. + +### 7.2 Pet host protocol + +`convax.pet-host/1` is separate from Canvas host protocols. Convax transfers a +fresh MessagePort to each mounted Pet surface after verifying the installed Plugin, +manifest entry, document URL, renderer identity, and requested capabilities. + +The overlay and settings surfaces receive different allowlists: + +| Surface | Allowed operation groups | +| --- | --- | +| Overlay | activity snapshot/events, activity open, preferences, overlay move/expand | +| Settings | preferences, wake/tuck | + +Neither surface can forward arbitrary method names. Requests and responses are +versioned, uniquely identified, size-bounded, schema-validated, and rejected after +the surface or Plugin generation changes. + +### 7.3 Host method semantics + +The protocol exposes narrow operations equivalent to: + +```text +activity.getSnapshot / activity.changed +activity.open +overlay.move / overlay.setExpanded +preferences.get / preferences.update +lifecycle.setAwake +``` + +Method names are part of the versioned contract; the implementation plan may group +request and event types differently without expanding their authority. + +`activity.open` accepts only an opaque activity ID and the snapshot revision. +`preferences.update` accepts an exact bounded schema, not arbitrary filesystem or +application settings. Pet images load from validated package-relative URLs in the +installed Plugin origin. + +## 8. Packaged Pet Library + +### 8.1 Data model + +The Plugin owns a static `convax.pet-library/1` document: + +```json +{ + "schema": "convax.pet-library/1", + "pets": [ + { + "id": "violet", + "displayName": "Violet", + "description": "A pixel companion for Convax.", + "spritesheet": "assets/violet.png", + "spriteVersion": 2, + "alt": "Violet, the Convax pixel companion" + } + ] +} +``` + +The document and every referenced asset are immutable package files validated at +repository validation and Plugin installation. IDs are unique and stable across +Plugin updates. The Plugin stores only the selected ID and presentation preferences +through the host preference API. + +Future pets are added by updating this same Plugin package, incrementing SemVer and +the Registry sequence. Removal of a previously shipped pet must migrate a stale +selection to Violet without waking a tucked overlay. + +### 8.2 Package validation + +Repository and host validation independently enforce: + +- exact library schema and no unknown keys; +- unique safe IDs and bounded display strings; +- normalized package-relative PNG/WebP paths; +- no URL, absolute path, traversal, symlink, reserved name, or case ambiguity; +- real PNG/WebP bytes matching the extension; +- exact 1536 by 1872 dimensions; +- alpha channel and useful transparency; +- maximum 20 MiB asset size; +- every asset remains within the installed package. + +The settings surface cannot add, replace, or delete library records at runtime. + +## 9. Activity and Presentation + +### 9.1 Host activity projection + +Convax aggregates every known project and session and emits bounded, +monotonically-revisioned activity snapshots. It may use event routing, bounded +recovery polling, global fairness, and backoff internally. The Plugin receives only: + +- opaque activity ID; +- opaque project and session identity required for freshness checks; +- already-approved user-visible project/session labels; +- normalized state and subtype; +- update timestamp and snapshot revision. + +It never receives prompts, messages, question or permission content, tool details, +paths, environment values, or credentials. + +### 9.2 Plugin presentation rules + +The Plugin applies exact global priority: + +```text +needs-input > blocked > ready > running > idle +``` + +Default mappings are: + +| Activity | Animation | +| --- | --- | +| Pending question | `waiting` | +| Pending permission | `review` | +| Blocked or terminal error | `failed` | +| Successful completion not yet displayed | `waving` | +| Busy, retry, or in-flight prompt | `running` | +| No visible activity | `idle` | + +Same-priority activities are ordered by most recent meaningful update. User +cancellation becomes idle, not blocked. Clicking the pet briefly plays `jumping` +before requesting navigation to the selected activity. + +### 9.3 Window and accessibility + +- Collapsed surface: approximately 176 by 176 logical pixels. +- Expanded tray: approximately 356 by 320 logical pixels with scrolling beyond + four visible activity rows. +- Drag begins after four logical pixels and may use directional running rows. +- The window never steals focus merely because activity changed. +- Reduced motion fixes each state to a representative still frame and creates no + animation timer. +- Expanded controls support keyboard navigation, activation, dismissal, labels, + and non-color status cues. +- The Plugin uses current Convax theme tokens, typography, spacing, and six-pixel + corner radius rather than reproducing Codex styling literally. + +## 10. Lifecycle and Persistence + +Installing the Plugin does not wake the pet. Enabling it mounts the settings +surface and prepares the provider. The user explicitly selects a pet and wakes the +overlay. + +Convax persists native lifecycle data separately from Plugin product data: + +- awake/tucked state; +- last valid position per display ID and scale context; +- active provider identity and generation; +- bounded navigation/read acknowledgements where host validation requires them. + +The Plugin persists its selected packaged pet and presentation preferences through +its bounded private store. No record contains native paths or Agent content. + +Disabling or uninstalling the provider closes the overlay, stops global activity +projection, revokes ports, and removes the Pet settings surface. Reinstall starts +from validated Plugin defaults unless compatible private preferences remain. + +Updates stage new package bytes before revoking the old generation. State is +reconnected only after the new manifest, library, assets, and entry points validate. +A failed update preserves the previous installed Plugin and selection. + +## 11. Security Model + +The overlay BrowserWindow uses: + +```text +contextIsolation: true +sandbox: true +nodeIntegration: false +frame: false +transparent: true +``` + +It uses a nonpersistent, Plugin-scoped Electron session. The installed asset +protocol is registered and removed on that same session. Convax blocks navigation, +popups, downloads, permission requests, remote resources, service workers, and +untrusted senders. CSP permits only the installed Plugin bundle and its validated +package assets. + +The fixed preload transfers only the surface-specific `convax.pet-host/1` port. It +does not expose Electron APIs, native paths, a generic IPC method, or unrestricted +fetch. Every asynchronous result is bound to the provider and document generation +that requested it. + +Installation and packing remain inert. Repository validation may inspect asset +bytes but never executes contributor package scripts during validation or packing. + +## 12. Failure Handling + +- Runtime temporarily unavailable: publish a generic unavailable activity and + retry with bounded backoff without leaking diagnostics. +- Stale activity or navigation revision: reject the action and let the Plugin + refresh its snapshot. +- Overlay renderer crash: recreate once; a second crash tucks the pet and leaves + Convax usable. +- Settings renderer crash: discard its port and remount through the normal Plugin + surface lifecycle. +- Missing or invalid packaged pet: reject installation or update before activation. +- Removed selected pet after a valid update: select Violet without waking the pet. +- Corrupt Plugin preferences: restore Violet and safe defaults. +- Display removal, resume, or scale change: clamp the overlay to a visible work + area, preferring the last display identity. +- App shutdown: reject new requests, revoke ports, persist atomically, stop + activity work, and close without blocking shutdown. + +## 13. Testing Strategy + +### 13.1 `convax-plugins` + +Schema, validator, packer, and Registry tests cover: + +- a valid Pet feature Plugin with both static entry points and a packaged library; +- capability and protocol mismatch, missing entry, unknown field, unsafe path, + symlink, remote resource, invalid atlas, executable, companion, and install hook + rejection; +- duplicate library IDs and missing assets; +- retention of unrelated `convax.plugin/5` contributions; +- deterministic ZIP layout and Registry metadata; +- package SemVer and Registry sequence requirements. + +Pet Plugin workspace tests cover: + +- packaged library parsing and stale-selection fallback; +- activity priority and animation mappings; +- library selection and preference recovery; +- overlay interactions, reduced motion, scrolling, keyboard behavior, and labels; +- settings selection, wake/tuck, and disconnected host requests. + +### 13.2 Convax host + +Host tests cover: + +- singleton provider activation without package-ID special cases; +- per-surface protocol allowlists, identity validation, generation revocation, and + bounded messages; +- isolated session protocol registration and navigation hardening; +- content-free, fair, bounded cross-project activity projection; +- opaque navigation resolution and stale revision rejection; +- validation and serving of library files and sprite assets; +- bounded preferences and native lifecycle persistence; +- wake/tuck, multi-display restore, crash recovery, update, uninstall, and shutdown. + +### 13.3 End-to-end acceptance + +1. Install and enable Convax Pet; the Plugin-owned settings surface appears but the + overlay remains tucked. +2. Select and wake bundled Violet; verify idle rendering. +3. Start work in project A and request input in project B; the Plugin applies the + documented priority. +4. Click the active row; Convax focuses and opens the correct project/session. +5. Enable reduced motion and verify no animation timer runs. +6. Restart and restore the selected packaged pet, wake state, and visible display + position. +7. Disable or uninstall the Plugin; the overlay and activity subscription stop. +8. Reinstall and recover valid preferences or safe Violet defaults. +9. Perform the complete flow offline with no remote request. + +## 14. Migration from the Prototype + +Because the current Pet contract has not been published, migration replaces it +before release rather than supporting two public models. + +### `convax-plugins` + +- Replace the one-pet manifest fields with feature `overlay`, `settings`, + `protocol`, and packaged-library entries. +- Move Violet metadata into `convax.pet-library/1` and add bundled static Web + surfaces. +- Move animation, priority, collection, and settings tests into the Plugin + workspace. +- Update schema, authoring, packaging, Registry documentation, tests, version, and + Registry sequence. + +### `/Users/bytedance/src/convax` + +- Keep and generalize the secure window, activity projection, navigation, + persistence, asset inspection, and lifecycle foundations. +- Replace host-owned pet React UI and product controller logic with provider + activation, surface mounting, scoped protocol ports, and bounded preferences. +- Remove raw spritesheet import and the assumption that installed Plugins each + contribute one selectable pet. +- Preserve the already-tested fairness, stale-result handling, multi-display + recovery, isolated session, and sandboxed preload behavior. + +No unrelated application or Plugin architecture is refactored. + +## 15. Required Verification + +In `convax-plugins`: + +```sh +bun install --frozen-lockfile --ignore-scripts +bun run workspaces:build:packages +bun run validate +bun run workspaces:typecheck +bun run workspaces:test +bun run build:companions +bun test +bun run pack +bun run build:index +``` + +In `/Users/bytedance/src/convax`: + +- run affected package typechecks and focused tests; +- run the complete root `bun run check`; +- run the built Electron open-project and Plugin smoke path; +- manually verify native multi-display drag/restore and reduced motion where + automated Electron coverage cannot prove OS behavior. + +## 16. Acceptance Criteria + +The revision is complete when: + +- installing one Convax Pet Plugin provides the Plugin-owned overlay, settings, + Violet, and packaged pet library; +- adding a future pet requires only a new library entry and bundled atlas in the + same Plugin release, not another Convax Plugin; +- disabling or uninstalling the feature Plugin removes its running feature; +- Convax contains only generic native/security primitives and no Violet, pet card, + animation mapping, raw pet import, or package-ID special case; +- Plugin code cannot access Agent content, native paths, unrestricted files, + Node/Electron, network, or arbitrary IPC; +- all repository, host, integration, accessibility, lifecycle, and security tests + pass. diff --git a/packages/plugins/convax-pet/activity.test.js b/packages/plugins/convax-pet/activity.test.js new file mode 100644 index 0000000..69c0cbb --- /dev/null +++ b/packages/plugins/convax-pet/activity.test.js @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" + +const activity = (id, state, updatedAt, subtype = "none") => ({ id, state, subtype, updatedAt }) + +describe("pet activity presentation", () => { + test("orders exact state priority before recency", async () => { + const { orderedActivities } = await import("./package/assets/activity.js") + const input = [ + activity("running", "running", 50), + activity("ready", "ready", 40), + activity("blocked", "blocked", 30), + activity("input-old", "needs-input", 10, "question"), + activity("input-new", "needs-input", 20, "permission"), + ] + + expect(orderedActivities(input).map(({ id }) => id)).toEqual([ + "input-new", + "input-old", + "blocked", + "ready", + "running", + ]) + expect(input.map(({ id }) => id)).toEqual(["running", "ready", "blocked", "input-old", "input-new"]) + }) + + test("maps normalized activity to atlas animations", async () => { + const { animationFor } = await import("./package/assets/activity.js") + + expect(animationFor()).toBe("idle") + expect(animationFor(activity("question", "needs-input", 1, "question"))).toBe("waiting") + expect(animationFor(activity("permission", "needs-input", 1, "permission"))).toBe("review") + expect(animationFor(activity("blocked", "blocked", 1))).toBe("failed") + expect(animationFor(activity("ready", "ready", 1))).toBe("waving") + expect(animationFor(activity("running", "running", 1))).toBe("running") + }) + + test("defines all nine fixed atlas rows and bounded status copy", async () => { + const { animations, statusText } = await import("./package/assets/activity.js") + + expect(Object.keys(animations)).toEqual([ + "idle", + "running-right", + "running-left", + "waving", + "jumping", + "failed", + "waiting", + "running", + "review", + ]) + expect(statusText(activity("permission", "needs-input", 1, "permission"))).toBe("Needs permission") + expect(statusText(activity("question", "needs-input", 1, "question"))).toBe("Needs an answer") + expect(statusText(activity("blocked", "blocked", 1))).toBe("Blocked") + expect(statusText(activity("ready", "ready", 1))).toBe("Ready") + expect(statusText(activity("running", "running", 1))).toBe("Running") + }) +}) diff --git a/packages/plugins/convax-pet/convax-package.json b/packages/plugins/convax-pet/convax-package.json new file mode 100644 index 0000000..cb62abc --- /dev/null +++ b/packages/plugins/convax-pet/convax-package.json @@ -0,0 +1,14 @@ +{ + "schema": "convax.package/1", + "kind": "plugin", + "id": "convax-pet", + "name": "Convax Pet", + "description": "A local desktop companion and pet library for Convax activity.", + "version": "0.2.1", + "license": "MIT", + "compatibility": { + "pluginSchema": "convax.plugin/5", + "pluginHost": "convax.plugin-capability/1" + }, + "yanked": false +} diff --git a/packages/plugins/convax-pet/overlay.test.js b/packages/plugins/convax-pet/overlay.test.js new file mode 100644 index 0000000..4acfe8e --- /dev/null +++ b/packages/plugins/convax-pet/overlay.test.js @@ -0,0 +1,92 @@ +import { describe, expect, mock, test } from "bun:test" +import fs from "node:fs/promises" + +describe("Plugin-owned pet overlay", () => { + test("selects atlas frames and holds the first frame for reduced motion", async () => { + const { frameFor } = await import("./package/pet/model.js") + + expect(frameFor("idle", 0, false)).toEqual({ column: 0, row: 0 }) + expect(frameFor("idle", 300, false)).toEqual({ column: 1, row: 0 }) + expect(frameFor("review", 5_000, true)).toEqual({ column: 0, row: 8 }) + }) + + test("maps atlas cells to normalized CSS background positions", async () => { + const { backgroundPositionFor } = await import("./package/pet/model.js") + + expect(backgroundPositionFor({ column: 3, row: 4 })).toBe(`${300 / 7}% 50%`) + expect(backgroundPositionFor({ column: 7, row: 8 })).toBe("100% 100%") + }) + + test("distinguishes click from a four-pixel drag", async () => { + const { createDragGesture } = await import("./package/pet/model.js") + const onDrag = mock(() => undefined) + const gesture = createDragGesture(onDrag) + + gesture.start({ clientX: 10, clientY: 10 }) + expect(gesture.move({ clientX: 12, clientY: 12 })).toBe(false) + expect(gesture.end({ clientX: 12, clientY: 12 })).toBe(false) + gesture.start({ clientX: 10, clientY: 10 }) + expect(gesture.move({ clientX: 15, clientY: 10 })).toBe(true) + expect(gesture.end({ clientX: 18, clientY: 8 })).toBe(true) + expect(onDrag).toHaveBeenCalledWith({ dx: 5, dy: 0, phase: "move" }) + expect(onDrag).toHaveBeenCalledWith({ dx: 3, dy: -2, phase: "end" }) + }) + + test("supports keyboard actions and jump-before-navigation", async () => { + const { activatePet, keyAction } = await import("./package/pet/model.js") + const calls = [] + + expect(keyAction("Enter", false)).toBe("activate") + expect(keyAction(" ", true)).toBe("activate") + expect(keyAction("Escape", true)).toBe("collapse") + expect(keyAction("Escape", false)).toBe("none") + await activatePet("activity-one", { + jump: () => calls.push("jump"), + navigate: async () => calls.push("navigate"), + wait: async () => calls.push("wait"), + }) + expect(calls).toEqual(["jump", "wait", "navigate"]) + }) + + test("contains activity.open failures and always resets jumping", async () => { + const { openActivity } = await import("./package/pet/model.js") + const client = { request: mock(() => Promise.reject(new Error("host unavailable"))) } + const calls = [] + + await expect(openActivity(client, { id: "activity-one" }, 7, { + jump: () => calls.push("jump"), + settle: () => calls.push("settle"), + wait: async () => calls.push("wait"), + })).resolves.toBeUndefined() + + expect(client.request).toHaveBeenCalledWith("activity.open", { activityId: "activity-one", revision: 7 }) + expect(calls).toEqual(["jump", "wait", "settle"]) + }) + + test("reconciles failed expansion requests to the previous state", async () => { + const { reconcileExpanded } = await import("./package/pet/model.js") + const client = { request: mock(() => Promise.reject(new Error("host unavailable"))) } + + await expect(reconcileExpanded(client, false, true)).resolves.toBe(false) + expect(client.request).toHaveBeenCalledWith("overlay.setExpanded", { expanded: true }) + }) + + test("contains overlay.move failures", async () => { + const { moveOverlay } = await import("./package/pet/model.js") + const client = { request: mock(() => Promise.reject(new Error("host unavailable"))) } + const input = { dx: 4, dy: -2, phase: "move" } + + await expect(moveOverlay(client, input)).resolves.toBeUndefined() + expect(client.request).toHaveBeenCalledWith("overlay.move", input) + }) + + test("ships only local sandbox-compatible surface dependencies", async () => { + const html = await fs.readFile(new URL("./package/pet/index.html", import.meta.url), "utf8") + const app = await fs.readFile(new URL("./package/pet/app.js", import.meta.url), "utf8") + + expect(html).toContain('') + expect(html).toContain('') + expect(html).not.toMatch(/https?:\/\/| + (priority[left.state] ?? Number.MAX_SAFE_INTEGER) - (priority[right.state] ?? Number.MAX_SAFE_INTEGER) || + right.updatedAt - left.updatedAt || + left.id.localeCompare(right.id), + ) +} + +export function animationFor(activity) { + if (!activity) return "idle" + if (activity.state === "needs-input") return activity.subtype === "permission" ? "review" : "waiting" + if (activity.state === "blocked") return "failed" + if (activity.state === "ready") return "waving" + return "running" +} + +export function statusText(activity) { + if (activity.state === "needs-input") return activity.subtype === "permission" ? "Needs permission" : "Needs an answer" + if (activity.state === "blocked") return "Blocked" + if (activity.state === "ready") return "Ready" + return "Running" +} diff --git a/packages/plugins/convax-pet/package/assets/pet-host.js b/packages/plugins/convax-pet/package/assets/pet-host.js new file mode 100644 index 0000000..4a9c3d3 --- /dev/null +++ b/packages/plugins/convax-pet/package/assets/pet-host.js @@ -0,0 +1,142 @@ +const protocol = "convax.pet-host/1" +const defaultHandshakeTimeoutMs = 5_000 +const defaultRequestTimeoutMs = 10_000 +const maximumTimeoutMs = 60_000 +const maximumPendingRequests = 64 +const maximumMessageCharacters = 64 * 1024 +const minimumTimeoutMs = 25 + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function bounded(value) { + try { + return JSON.stringify(value).length <= maximumMessageCharacters + } catch { + return false + } +} + +function timeoutFor(value, fallback) { + if (!Number.isFinite(value)) return fallback + return Math.min(maximumTimeoutMs, Math.max(minimumTimeoutMs, Math.trunc(value))) +} + +function clientForPort(port, requestTimeoutMs) { + const pending = new Map() + const listeners = new Map() + let closed = false + let nextId = 1 + + port.onmessage = ({ data }) => { + if (closed || !isRecord(data) || data.protocol !== protocol || !bounded(data)) return + if (data.type === "response" && typeof data.id === "string") { + const request = pending.get(data.id) + if (!request) return + pending.delete(data.id) + clearTimeout(request.timeoutId) + if (data.ok === true) request.resolve(data.result) + else request.reject(new Error(typeof data.error === "string" ? data.error.slice(0, 500) : "Pet host request failed")) + return + } + if (data.type === "event" && typeof data.event === "string") { + for (const listener of listeners.get(data.event) ?? []) listener(data.payload) + } + } + port.start() + + return Object.freeze({ + close() { + if (closed) return + closed = true + port.onmessage = null + for (const request of pending.values()) { + clearTimeout(request.timeoutId) + request.reject(new Error("Pet host connection closed")) + } + pending.clear() + listeners.clear() + port.close() + }, + request(method, params) { + if (closed) return Promise.reject(new Error("Pet host connection closed")) + if (typeof method !== "string" || method.length < 1 || method.length > 80 || !bounded(params)) { + return Promise.reject(new Error("Pet host request is invalid")) + } + if (pending.size >= maximumPendingRequests) return Promise.reject(new Error("Pet host pending request limit reached")) + const id = String(nextId++) + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const request = pending.get(id) + if (!request) return + pending.delete(id) + request.reject(new Error("Pet host request timed out")) + }, requestTimeoutMs) + pending.set(id, { reject, resolve, timeoutId }) + try { + port.postMessage({ id, method, params, protocol, type: "request" }) + } catch (error) { + clearTimeout(timeoutId) + pending.delete(id) + reject(error) + } + }) + }, + subscribe(event, listener) { + if (closed || typeof event !== "string" || event.length < 1 || event.length > 80 || typeof listener !== "function") { + return () => undefined + } + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + return () => { + eventListeners.delete(listener) + if (eventListeners.size === 0) listeners.delete(event) + } + }, + }) +} + +export function connectPetHost({ + expectedSource = window.parent, + handshakeTimeoutMs = defaultHandshakeTimeoutMs, + pluginId, + requestTimeoutMs = defaultRequestTimeoutMs, + source = window, + surface, +}) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(pluginId) || (surface !== "overlay" && surface !== "settings")) { + return Promise.reject(new Error("Pet host connection scope is invalid")) + } + const boundedHandshakeTimeoutMs = timeoutFor(handshakeTimeoutMs, defaultHandshakeTimeoutMs) + const boundedRequestTimeoutMs = timeoutFor(requestTimeoutMs, defaultRequestTimeoutMs) + return new Promise((resolve, reject) => { + let timeoutId + const cleanup = () => { + source.removeEventListener("message", connect) + clearTimeout(timeoutId) + } + const connect = (event) => { + const message = event.data + if ( + event.source !== expectedSource || + !isRecord(message) || + message.protocol !== protocol || + message.type !== "connect" || + message.pluginId !== pluginId || + message.surface !== surface || + event.ports?.length !== 1 + ) { + return + } + cleanup() + resolve(clientForPort(event.ports[0], boundedRequestTimeoutMs)) + } + source.addEventListener("message", connect) + timeoutId = setTimeout(() => { + cleanup() + reject(new Error("Pet host connection timed out")) + }, boundedHandshakeTimeoutMs) + }) +} diff --git a/packages/plugins/convax-pet/package/assets/pet-library.js b/packages/plugins/convax-pet/package/assets/pet-library.js new file mode 100644 index 0000000..6f28c95 --- /dev/null +++ b/packages/plugins/convax-pet/package/assets/pet-library.js @@ -0,0 +1,17 @@ +const pets = Object.freeze([ + Object.freeze({ + id: "violet", + displayName: "Violet", + description: "A pixel companion for Convax.", + spritesheet: "assets/violet.png", + spriteVersion: 2, + alt: "Violet, the Convax pixel companion", + }), +]) + +export const petLibrary = Object.freeze({ schema: "convax.pet-library/1", pets }) + +export function selectedPet(id) { + const pet = petLibrary.pets.find((candidate) => candidate.id === id) ?? petLibrary.pets[0] + return { ...pet } +} diff --git a/packages/plugins/convax-pet/package/assets/violet.png b/packages/plugins/convax-pet/package/assets/violet.png new file mode 100644 index 0000000..f54bff0 Binary files /dev/null and b/packages/plugins/convax-pet/package/assets/violet.png differ diff --git a/packages/plugins/convax-pet/package/manifest.json b/packages/plugins/convax-pet/package/manifest.json new file mode 100644 index 0000000..c426f9f --- /dev/null +++ b/packages/plugins/convax-pet/package/manifest.json @@ -0,0 +1,20 @@ +{ + "schema": "convax.plugin/5", + "id": "convax-pet", + "name": "Convax Pet", + "description": "A local desktop companion and pet library for Convax activity.", + "version": "0.2.1", + "capabilities": [ + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write" + ], + "contributes": { + "pet": { + "library": "pet-library.json", + "overlay": "pet/index.html", + "settings": "settings/index.html", + "protocol": "convax.pet-host/1" + } + } +} diff --git a/packages/plugins/convax-pet/package/pet-library.json b/packages/plugins/convax-pet/package/pet-library.json new file mode 100644 index 0000000..bc3653c --- /dev/null +++ b/packages/plugins/convax-pet/package/pet-library.json @@ -0,0 +1,13 @@ +{ + "schema": "convax.pet-library/1", + "pets": [ + { + "id": "violet", + "displayName": "Violet", + "description": "A pixel companion for Convax.", + "spritesheet": "assets/violet.png", + "spriteVersion": 2, + "alt": "Violet, the Convax pixel companion" + } + ] +} diff --git a/packages/plugins/convax-pet/package/pet/app.js b/packages/plugins/convax-pet/package/pet/app.js new file mode 100644 index 0000000..d0eff56 --- /dev/null +++ b/packages/plugins/convax-pet/package/pet/app.js @@ -0,0 +1,175 @@ +import { animationFor, orderedActivities, statusText } from "../assets/activity.js" +import { connectPetHost } from "../assets/pet-host.js" +import { selectedPet } from "../assets/pet-library.js" +import { + backgroundPositionFor, + createDragGesture, + frameFor, + keyAction, + moveOverlay, + openActivity, + reconcileExpanded, +} from "./model.js" + +const root = document.querySelector("#pet-root") +const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)") +let client +let expanded = false +let jumping = false +let animationStarted = performance.now() +let animationFrame +let snapshot = { activities: [], preferences: { selectedPetId: "violet" }, revision: 0 } + +function element(tag, className, text) { + const node = document.createElement(tag) + if (className) node.className = className + if (text !== undefined) node.textContent = text + return node +} + +function currentAnimation() { + return jumping ? "jumping" : animationFor(snapshot.activities[0]) +} + +function paintSprite(sprite) { + const frame = frameFor(currentAnimation(), performance.now() - animationStarted, reducedMotion.matches) + sprite.style.backgroundPosition = backgroundPositionFor(frame) + if (!reducedMotion.matches) animationFrame = requestAnimationFrame(() => paintSprite(sprite)) +} + +function stopAnimation() { + if (animationFrame !== undefined) cancelAnimationFrame(animationFrame) + animationFrame = undefined +} + +async function setExpanded(next) { + const previous = expanded + expanded = next + render() + const reconciled = await reconcileExpanded(client, previous, next) + if (expanded !== next || reconciled === next) return + expanded = reconciled + render() +} + +async function navigate(activity) { + await openActivity(client, activity, snapshot.revision, { + jump() { + jumping = true + animationStarted = performance.now() + render() + }, + settle() { + jumping = false + animationStarted = performance.now() + render() + }, + wait: () => new Promise((resolve) => window.setTimeout(resolve, reducedMotion.matches ? 0 : 180)), + }) +} + +function activityRow(activity) { + const button = element("button", "pet-activity") + button.type = "button" + button.addEventListener("click", () => void navigate(activity)) + const dot = element("span", `pet-dot pet-dot--${activity.state}`) + dot.setAttribute("aria-hidden", "true") + const copy = element("span", "pet-activity__copy") + copy.append(element("strong", "", activity.sessionLabel), element("span", "", activity.projectLabel)) + button.append(dot, copy, element("span", "pet-activity__status", statusText(activity))) + return button +} + +function render() { + stopAnimation() + root.replaceChildren() + root.className = expanded ? "pet-shell pet-shell--expanded" : "pet-shell" + const activities = orderedActivities(snapshot.activities) + const primary = activities[0] + const pet = selectedPet(snapshot.preferences.selectedPetId) + + if (expanded) { + const tray = element("section", "pet-tray") + tray.setAttribute("aria-label", "Agent activity") + const header = element("header", "pet-tray__header") + const heading = element("div") + heading.append(element("p", "pet-eyebrow", "Convax companion"), element("h1", "", pet.displayName)) + const close = element("button", "pet-icon-button", "×") + close.type = "button" + close.setAttribute("aria-label", "Close activity tray") + close.addEventListener("click", () => void setExpanded(false)) + header.append(heading, close) + const list = element("div", "pet-activity-list") + if (activities.length) list.append(...activities.map(activityRow)) + else list.append(element("p", "pet-empty", `All caught up. ${pet.displayName} is keeping watch.`)) + tray.append(header, list) + root.append(tray) + } + + const stage = element("div", "pet-stage") + const spriteButton = element("button", "pet-sprite-button") + spriteButton.type = "button" + spriteButton.tabIndex = expanded ? 0 : -1 + spriteButton.title = primary ? statusText(primary) : pet.description + spriteButton.setAttribute("aria-label", primary ? `${pet.displayName}: ${statusText(primary)}` : pet.alt) + const sprite = element("span", "pet-sprite") + sprite.setAttribute("aria-hidden", "true") + sprite.style.backgroundImage = `url("${new URL(`../${pet.spritesheet}`, import.meta.url)}")` + spriteButton.append(sprite) + const drag = createDragGesture((input) => void moveOverlay(client, input)) + let dragged = false + spriteButton.addEventListener("pointerdown", (event) => { + dragged = false + spriteButton.setPointerCapture(event.pointerId) + drag.start(event) + }) + spriteButton.addEventListener("pointermove", (event) => { + dragged = drag.move(event) || dragged + }) + spriteButton.addEventListener("pointerup", (event) => { + dragged = drag.end(event) || dragged + }) + spriteButton.addEventListener("click", () => { + if (!dragged && primary) void navigate(primary) + }) + spriteButton.addEventListener("keydown", (event) => { + const action = keyAction(event.key, expanded) + if (action === "activate" && primary) { + event.preventDefault() + void navigate(primary) + } else if (action === "collapse") { + event.preventDefault() + void setExpanded(false) + } + }) + const toggle = element("button", `pet-tray-toggle pet-tray-toggle--${primary?.state ?? "idle"}`, primary ? statusText(primary) : "Idle") + toggle.type = "button" + toggle.setAttribute("aria-expanded", String(expanded)) + toggle.addEventListener("click", () => void setExpanded(!expanded)) + stage.append(spriteButton, toggle) + root.append(stage) + animationStarted = performance.now() + paintSprite(sprite) +} + +async function start() { + client = await connectPetHost({ pluginId: "convax-pet", surface: "overlay" }) + const initial = await client.request("activity.getSnapshot", {}) + const preferences = await client.request("preferences.get", {}) + snapshot = { ...initial, activities: orderedActivities(initial.activities), preferences } + client.subscribe("activity.changed", (next) => { + if (next.revision < snapshot.revision) return + snapshot = { ...snapshot, ...next, activities: orderedActivities(next.activities) } + render() + }) + client.subscribe("preferences.changed", (preferencesNext) => { + snapshot = { ...snapshot, preferences: preferencesNext } + render() + }) + reducedMotion.addEventListener("change", render) + render() +} + +start().catch(() => { + root.replaceChildren(element("p", "pet-disconnected", "Pet host unavailable")) +}) diff --git a/packages/plugins/convax-pet/package/pet/index.html b/packages/plugins/convax-pet/package/pet/index.html new file mode 100644 index 0000000..847955e --- /dev/null +++ b/packages/plugins/convax-pet/package/pet/index.html @@ -0,0 +1,15 @@ + + + + + + + + Convax Pet + + + +
+ + + diff --git a/packages/plugins/convax-pet/package/pet/model.js b/packages/plugins/convax-pet/package/pet/model.js new file mode 100644 index 0000000..e7f7fa1 --- /dev/null +++ b/packages/plugins/convax-pet/package/pet/model.js @@ -0,0 +1,95 @@ +import { animations } from "../assets/activity.js" + +export function backgroundPositionFor({ column, row }) { + return `${(column / 7) * 100}% ${(row / 8) * 100}%` +} + +export function frameFor(animation, elapsed, reducedMotion) { + const definition = animations[animation] ?? animations.idle + if (reducedMotion) return { column: 0, row: definition.row } + const duration = definition.durations.reduce((total, value) => total + value, 0) + let remainder = Math.max(0, elapsed) % duration + for (let column = 0; column < definition.durations.length; column += 1) { + if (remainder < definition.durations[column]) return { column, row: definition.row } + remainder -= definition.durations[column] + } + return { column: 0, row: definition.row } +} + +export function createDragGesture(onDrag) { + let origin + let previous + let dragging = false + return { + end(point) { + if (!origin) return false + const wasDragging = dragging + if (dragging) onDrag({ dx: point.clientX - previous.clientX, dy: point.clientY - previous.clientY, phase: "end" }) + origin = undefined + previous = undefined + dragging = false + return wasDragging + }, + move(point) { + if (!origin) return false + if (!dragging && Math.hypot(point.clientX - origin.clientX, point.clientY - origin.clientY) >= 4) dragging = true + if (dragging) onDrag({ dx: point.clientX - previous.clientX, dy: point.clientY - previous.clientY, phase: "move" }) + previous = point + return dragging + }, + start(point) { + origin = point + previous = point + dragging = false + }, + } +} + +export function keyAction(key, expanded) { + if (key === "Enter" || key === " ") return "activate" + if (key === "Escape" && expanded) return "collapse" + return "none" +} + +export async function activatePet(activityId, { jump, navigate, wait }) { + if (!activityId) return + jump() + await wait() + await navigate() +} + +export async function moveOverlay(client, input) { + try { + await client.request("overlay.move", input) + } catch { + // Drag feedback is best-effort; the next host snapshot remains authoritative. + } +} + +export async function openActivity(client, activity, revision, { jump, settle, wait }) { + if (!activity?.id) return + try { + await activatePet(activity.id, { + jump, + navigate: async () => { + try { + await client.request("activity.open", { activityId: activity.id, revision }) + } catch { + // The overlay remains usable when the target activity disappears. + } + }, + wait, + }) + } finally { + settle() + } +} + +export async function reconcileExpanded(client, previous, next) { + try { + await client.request("overlay.setExpanded", { expanded: next }) + return next + } catch { + return previous + } +} diff --git a/packages/plugins/convax-pet/package/pet/styles.css b/packages/plugins/convax-pet/package/pet/styles.css new file mode 100644 index 0000000..53829ef --- /dev/null +++ b/packages/plugins/convax-pet/package/pet/styles.css @@ -0,0 +1,230 @@ +:root { + color: #171a18; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html, +body, +#pet-root { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent; +} + +button { + color: inherit; + font: inherit; +} + +.pet-shell { + position: relative; + width: 100%; + height: 100%; + user-select: none; +} + +.pet-stage { + position: absolute; + right: 0; + bottom: 0; + display: grid; + justify-items: center; + width: 176px; + height: 176px; + padding: 8px 12px 10px; +} + +.pet-sprite-button { + width: 124px; + height: 134px; + padding: 0; + border: 0; + background: transparent; + cursor: grab; + touch-action: none; +} + +.pet-sprite-button:active { + cursor: grabbing; +} + +.pet-sprite { + display: block; + width: 124px; + height: 134px; + background-repeat: no-repeat; + background-size: 800% 900%; + image-rendering: pixelated; + filter: drop-shadow(0 5px 7px rgb(23 26 24 / 22%)); +} + +.pet-tray-toggle { + min-width: 64px; + height: 24px; + padding: 0 9px; + overflow: hidden; + border: 1px solid rgb(217 221 218 / 92%); + border-radius: 999px; + background: rgb(255 255 255 / 92%); + box-shadow: 0 3px 12px rgb(23 26 24 / 12%); + font-size: 11px; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.pet-tray-toggle--needs-input { color: #9a6700; } +.pet-tray-toggle--blocked { color: #c43d49; } +.pet-tray-toggle--ready { color: #1f7a4d; } +.pet-tray-toggle--running { color: #7657e8; } + +.pet-tray { + position: absolute; + top: 0; + left: 0; + width: 356px; + max-height: 232px; + overflow: hidden; + border: 1px solid rgb(217 221 218 / 94%); + border-radius: 6px; + background: rgb(247 248 247 / 96%); + box-shadow: 0 12px 34px rgb(23 26 24 / 20%); +} + +.pet-tray__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 14px 10px; + border-bottom: 1px solid #d9ddda; +} + +.pet-eyebrow { + margin: 0 0 3px; + color: #68716c; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pet-tray h1 { + margin: 0; + font-size: 14px; +} + +.pet-icon-button { + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 20px; + cursor: pointer; +} + +.pet-icon-button:hover, +.pet-activity:hover { + background: #eef0ee; +} + +.pet-activity-list { + max-height: 174px; + overflow-y: auto; + padding: 6px; +} + +.pet-activity { + display: grid; + grid-template-columns: 9px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + width: 100%; + padding: 9px; + border: 0; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.pet-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #7657e8; +} + +.pet-dot--needs-input { background: #bd7b00; } +.pet-dot--blocked { background: #c43d49; } +.pet-dot--ready { background: #258358; } + +.pet-activity__copy { + display: grid; + min-width: 0; +} + +.pet-activity__copy strong, +.pet-activity__copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pet-activity__copy strong { + font-size: 12px; +} + +.pet-activity__copy span, +.pet-activity__status, +.pet-empty, +.pet-disconnected { + color: #68716c; + font-size: 11px; +} + +.pet-empty, +.pet-disconnected { + margin: 0; + padding: 24px 14px; + text-align: center; +} + +.pet-sprite-button:focus-visible, +.pet-tray-toggle:focus-visible, +.pet-icon-button:focus-visible, +.pet-activity:focus-visible { + outline: 2px solid #7657e8; + outline-offset: 2px; +} + +@media (prefers-color-scheme: dark) { + :root { color: #f1f3f1; } + .pet-tray { border-color: #3a403c; background: rgb(28 31 29 / 96%); } + .pet-tray__header { border-color: #3a403c; } + .pet-tray-toggle { border-color: #3a403c; background: rgb(28 31 29 / 94%); } + .pet-icon-button:hover, + .pet-activity:hover { background: #303532; } + .pet-eyebrow, + .pet-activity__copy span, + .pet-activity__status, + .pet-empty, + .pet-disconnected { color: #aab1ad; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + } +} diff --git a/packages/plugins/convax-pet/package/settings/app.js b/packages/plugins/convax-pet/package/settings/app.js new file mode 100644 index 0000000..6846bd7 --- /dev/null +++ b/packages/plugins/convax-pet/package/settings/app.js @@ -0,0 +1,84 @@ +import { connectPetHost } from "../assets/pet-host.js" +import { petLibrary } from "../assets/pet-library.js" +import { normalizePreferences, selectPreference, wakeRequest } from "./model.js" + +const root = document.querySelector("#settings-root") +let client +let preferences = normalizePreferences() +let errorMessage = "" + +function element(tag, className, text) { + const node = document.createElement(tag) + if (className) node.className = className + if (text !== undefined) node.textContent = text + return node +} + +async function mutate(operation) { + errorMessage = "" + try { + preferences = normalizePreferences(await operation()) + } catch { + errorMessage = "Pet preferences could not be updated." + } + render() +} + +function petCard(pet) { + const selected = pet.id === preferences.selectedPetId + const button = element("button", selected ? "pet-card pet-card--selected" : "pet-card") + button.type = "button" + button.setAttribute("aria-pressed", String(selected)) + button.addEventListener("click", () => + void mutate(() => client.request("preferences.update", selectPreference(preferences, pet.id))), + ) + const preview = element("span", "pet-preview") + preview.setAttribute("role", "img") + preview.setAttribute("aria-label", pet.alt) + preview.style.backgroundImage = `url("${new URL(`../${pet.spritesheet}`, import.meta.url)}")` + const copy = element("span", "pet-card__copy") + const title = element("span", "pet-card__title", pet.displayName) + if (selected) title.append(element("span", "pet-selected", "Selected")) + copy.append(title, element("span", "pet-card__description", pet.description)) + button.append(preview, copy) + return button +} + +function render() { + root.replaceChildren() + const header = element("header", "settings-header") + const heading = element("div") + heading.append( + element("p", "settings-eyebrow", "DESKTOP COMPANION"), + element("h1", "", "Pets"), + element("p", "settings-description", "Choose a companion that reflects Agent activity across your projects."), + ) + const lifecycle = element("button", "lifecycle-button", preferences.awake ? "Tuck away" : "Wake pet") + lifecycle.type = "button" + lifecycle.addEventListener("click", () => + void mutate(() => client.request("lifecycle.setAwake", wakeRequest(!preferences.awake))), + ) + header.append(heading, lifecycle) + const section = element("section", "collection") + section.setAttribute("aria-label", "Pet collection") + section.append(element("h2", "", "Pet collection"), element("p", "collection-description", "Selection does not wake a tucked pet.")) + const grid = element("div", "pet-grid") + grid.append(...petLibrary.pets.map(petCard)) + section.append(grid) + root.append(header, section) + if (errorMessage) root.append(element("p", "settings-error", errorMessage)) +} + +async function start() { + client = await connectPetHost({ pluginId: "convax-pet", surface: "settings" }) + preferences = normalizePreferences(await client.request("preferences.get", {})) + client.subscribe("preferences.changed", (next) => { + preferences = normalizePreferences(next) + render() + }) + render() +} + +start().catch(() => { + root.replaceChildren(element("p", "settings-error", "Pet settings are disconnected from Convax.")) +}) diff --git a/packages/plugins/convax-pet/package/settings/index.html b/packages/plugins/convax-pet/package/settings/index.html new file mode 100644 index 0000000..d094f08 --- /dev/null +++ b/packages/plugins/convax-pet/package/settings/index.html @@ -0,0 +1,15 @@ + + + + + + + + Pet settings + + + +
+ + + diff --git a/packages/plugins/convax-pet/package/settings/model.js b/packages/plugins/convax-pet/package/settings/model.js new file mode 100644 index 0000000..b296d0b --- /dev/null +++ b/packages/plugins/convax-pet/package/settings/model.js @@ -0,0 +1,16 @@ +import { selectedPet } from "../assets/pet-library.js" + +export function normalizePreferences(value) { + return { + awake: value?.awake === true, + selectedPetId: selectedPet(value?.selectedPetId).id, + } +} + +export function selectPreference(_current, selectedPetId) { + return { selectedPetId: selectedPet(selectedPetId).id } +} + +export function wakeRequest(awake) { + return { awake: awake === true } +} diff --git a/packages/plugins/convax-pet/package/settings/styles.css b/packages/plugins/convax-pet/package/settings/styles.css new file mode 100644 index 0000000..71fd165 --- /dev/null +++ b/packages/plugins/convax-pet/package/settings/styles.css @@ -0,0 +1,158 @@ +:root { + color: #171a18; + background: #f7f8f7; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: transparent; +} + +button { + color: inherit; + font: inherit; +} + +#settings-root { + display: grid; + gap: 24px; + min-height: 320px; + padding: 2px; +} + +.settings-header { + display: flex; + gap: 24px; + align-items: flex-start; + justify-content: space-between; +} + +.settings-eyebrow { + margin: 0 0 5px; + color: #7657e8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 6px; + font-size: 18px; +} + +h2 { + margin-bottom: 4px; + font-size: 14px; +} + +.settings-description, +.collection-description, +.pet-card__description { + color: #68716c; + font-size: 12px; + line-height: 1.55; +} + +.lifecycle-button { + min-width: 92px; + height: 34px; + padding: 0 13px; + border: 1px solid #d9ddda; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.pet-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.pet-card { + display: grid; + grid-template-columns: 68px minmax(0, 1fr); + gap: 12px; + align-items: center; + min-height: 92px; + padding: 10px; + border: 1px solid #d9ddda; + border-radius: 6px; + background: #fff; + text-align: left; + cursor: pointer; +} + +.pet-card--selected { + border-color: #7657e8; + box-shadow: 0 0 0 1px #7657e8; +} + +.pet-preview { + width: 68px; + height: 74px; + background-position: 0 0; + background-repeat: no-repeat; + background-size: 800% 900%; + image-rendering: pixelated; +} + +.pet-card__copy, +.pet-card__title { + display: grid; + gap: 4px; + min-width: 0; +} + +.pet-card__title { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + font-size: 13px; + font-weight: 600; +} + +.pet-selected { + padding: 2px 6px; + border-radius: 999px; + color: #7657e8; + background: #eeeafd; + font-size: 10px; + font-weight: 600; +} + +.settings-error { + padding: 9px 11px; + border: 1px solid #e8b9bd; + border-radius: 6px; + color: #9e303b; + background: #fff1f2; + font-size: 12px; +} + +button:focus-visible { + outline: 2px solid #7657e8; + outline-offset: 2px; +} + +@media (prefers-color-scheme: dark) { + :root { color: #f1f3f1; background: #1c1f1d; } + .lifecycle-button, + .pet-card { border-color: #3a403c; background: #242824; } + .settings-description, + .collection-description, + .pet-card__description { color: #aab1ad; } + .pet-selected { background: #322b4e; } +} diff --git a/packages/plugins/convax-pet/pet-host.test.js b/packages/plugins/convax-pet/pet-host.test.js new file mode 100644 index 0000000..05f3918 --- /dev/null +++ b/packages/plugins/convax-pet/pet-host.test.js @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test" + +class FakeSource { + listeners = new Set() + + addEventListener(type, listener) { + if (type === "message") this.listeners.add(listener) + } + + removeEventListener(type, listener) { + if (type === "message") this.listeners.delete(listener) + } + + emit(event) { + for (const listener of [...this.listeners]) listener(event) + } +} + +class FakePort { + close = mock(() => undefined) + postMessage = mock(() => undefined) + start = mock(() => undefined) + onmessage = null + + emit(data) { + this.onmessage?.({ data }) + } +} + +afterEach(() => { + jest.useRealTimers() +}) + +describe("pet host client", () => { + test("accepts one exact scoped connection and matches responses", async () => { + const { connectPetHost } = await import("./package/assets/pet-host.js") + const source = new FakeSource() + const parent = {} + const port = new FakePort() + const connecting = connectPetHost({ expectedSource: parent, pluginId: "convax-pet", source, surface: "settings" }) + + source.emit({ data: { pluginId: "other", protocol: "convax.pet-host/1", surface: "settings", type: "connect" }, ports: [port], source: parent }) + source.emit({ data: { pluginId: "convax-pet", protocol: "wrong", surface: "settings", type: "connect" }, ports: [port], source: parent }) + source.emit({ data: { pluginId: "convax-pet", protocol: "convax.pet-host/1", surface: "settings", type: "connect" }, ports: [port], source: parent }) + + const client = await connecting + expect(port.start).toHaveBeenCalledTimes(1) + const response = client.request("preferences.get", {}) + expect(port.postMessage).toHaveBeenCalledWith({ + id: "1", + method: "preferences.get", + params: {}, + protocol: "convax.pet-host/1", + type: "request", + }) + port.emit({ id: "1", ok: true, protocol: "convax.pet-host/1", result: { selectedPetId: "violet" }, type: "response" }) + expect(await response).toEqual({ selectedPetId: "violet" }) + }) + + test("delivers bounded events and rejects pending work when closed", async () => { + const { connectPetHost } = await import("./package/assets/pet-host.js") + const source = new FakeSource() + const parent = {} + const port = new FakePort() + const connecting = connectPetHost({ expectedSource: parent, pluginId: "convax-pet", source, surface: "overlay" }) + source.emit({ data: { pluginId: "convax-pet", protocol: "convax.pet-host/1", surface: "overlay", type: "connect" }, ports: [port], source: parent }) + const client = await connecting + const listener = mock(() => undefined) + const unsubscribe = client.subscribe("activity.changed", listener) + + port.emit({ event: "activity.changed", payload: { revision: 2 }, protocol: "convax.pet-host/1", type: "event" }) + expect(listener).toHaveBeenCalledWith({ revision: 2 }) + unsubscribe() + port.emit({ event: "activity.changed", payload: { revision: 3 }, protocol: "convax.pet-host/1", type: "event" }) + expect(listener).toHaveBeenCalledTimes(1) + + const pendingSnapshot = client.request("activity.getSnapshot", {}) + const pendingPreferences = client.request("preferences.get", {}) + client.close() + await expect(pendingSnapshot).rejects.toThrow("closed") + await expect(pendingPreferences).rejects.toThrow("closed") + await expect(client.request("activity.getSnapshot", {})).rejects.toThrow("closed") + expect(port.close).toHaveBeenCalledTimes(1) + }) + + test("caps pending requests at 64", async () => { + const { connectPetHost } = await import("./package/assets/pet-host.js") + const source = new FakeSource() + const parent = {} + const port = new FakePort() + const connecting = connectPetHost({ expectedSource: parent, pluginId: "convax-pet", source, surface: "overlay" }) + source.emit({ data: { pluginId: "convax-pet", protocol: "convax.pet-host/1", surface: "overlay", type: "connect" }, ports: [port], source: parent }) + const client = await connecting + const pending = Array.from({ length: 64 }, () => client.request("activity.getSnapshot", {}).catch(() => undefined)) + + await expect(client.request("activity.getSnapshot", {})).rejects.toThrow("pending request limit") + client.close() + await Promise.all(pending) + }) + + test("times out an absent host and removes the handshake listener", async () => { + jest.useFakeTimers() + const { connectPetHost } = await import("./package/assets/pet-host.js") + const source = new FakeSource() + let timeoutError + const connecting = connectPetHost({ + expectedSource: {}, + handshakeTimeoutMs: 25, + pluginId: "convax-pet", + source, + surface: "overlay", + }) + void connecting.catch((error) => { + timeoutError = error + }) + + expect(source.listeners.size).toBe(1) + jest.advanceTimersByTime(25) + await Promise.resolve() + + expect(timeoutError?.message).toContain("timed out") + expect(source.listeners.size).toBe(0) + }) + + test("times out a silent request and removes it from pending work", async () => { + jest.useFakeTimers() + const { connectPetHost } = await import("./package/assets/pet-host.js") + const source = new FakeSource() + const parent = {} + const port = new FakePort() + const connecting = connectPetHost({ + expectedSource: parent, + pluginId: "convax-pet", + requestTimeoutMs: 25, + source, + surface: "settings", + }) + source.emit({ data: { pluginId: "convax-pet", protocol: "convax.pet-host/1", surface: "settings", type: "connect" }, ports: [port], source: parent }) + const client = await connecting + let timeoutError + const silent = client.request("preferences.get", {}) + void silent.catch((error) => { + timeoutError = error + }) + + jest.advanceTimersByTime(25) + await Promise.resolve() + expect(timeoutError?.message).toContain("timed out") + + const next = client.request("preferences.get", {}) + port.emit({ id: "2", ok: true, protocol: "convax.pet-host/1", result: { selectedPetId: "violet" }, type: "response" }) + expect(await next).toEqual({ selectedPetId: "violet" }) + + const pending = Array.from({ length: 64 }, () => client.request("preferences.get", {}).catch(() => undefined)) + expect(port.postMessage).toHaveBeenCalledTimes(66) + await expect(client.request("preferences.get", {})).rejects.toThrow("pending request limit") + client.close() + await Promise.all(pending) + }) +}) diff --git a/packages/plugins/convax-pet/pet-library.test.js b/packages/plugins/convax-pet/pet-library.test.js new file mode 100644 index 0000000..7ce9592 --- /dev/null +++ b/packages/plugins/convax-pet/pet-library.test.js @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" + +describe("packaged pet library", () => { + test("matches the inert JSON source and resolves a safe selection", async () => { + const module = await import("./package/assets/pet-library.js") + const json = JSON.parse(await fs.readFile(new URL("./package/pet-library.json", import.meta.url), "utf8")) + + expect(module.petLibrary).toEqual(json) + expect(json.pets.map((pet) => pet.spritesheet)).toEqual(["assets/violet.png"]) + expect(module.selectedPet("violet").id).toBe("violet") + expect(module.selectedPet("missing").id).toBe("violet") + }) + + test("does not expose mutable shared library data", async () => { + const { petLibrary, selectedPet } = await import("./package/assets/pet-library.js") + + expect(Object.isFrozen(petLibrary)).toBe(true) + expect(Object.isFrozen(petLibrary.pets)).toBe(true) + expect(Object.isFrozen(petLibrary.pets[0])).toBe(true) + expect(selectedPet("violet")).not.toBe(petLibrary.pets[0]) + }) +}) diff --git a/packages/plugins/convax-pet/settings.test.js b/packages/plugins/convax-pet/settings.test.js new file mode 100644 index 0000000..b45bad0 --- /dev/null +++ b/packages/plugins/convax-pet/settings.test.js @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" + +describe("Plugin-owned pet settings", () => { + test("normalizes stale selection to Violet without changing wake state", async () => { + const { normalizePreferences, selectPreference } = await import("./package/settings/model.js") + + expect(normalizePreferences({ awake: false, selectedPetId: "missing" })).toEqual({ + awake: false, + selectedPetId: "violet", + }) + expect(selectPreference({ awake: true, selectedPetId: "violet" }, "violet")).toEqual({ + selectedPetId: "violet", + }) + }) + + test("keeps wake and tuck as explicit lifecycle requests", async () => { + const { wakeRequest } = await import("./package/settings/model.js") + + expect(wakeRequest(true)).toEqual({ awake: true }) + expect(wakeRequest(false)).toEqual({ awake: false }) + }) + + test("ships a local collection surface without import or deletion controls", async () => { + const html = await fs.readFile(new URL("./package/settings/index.html", import.meta.url), "utf8") + const app = await fs.readFile(new URL("./package/settings/app.js", import.meta.url), "utf8") + const combined = `${html}\n${app}` + + expect(html).toContain('') + expect(html).toContain('') + expect(combined).not.toMatch(/\b(?:import custom|upload|delete pet|pet\.json|file input)\b/i) + expect(combined).not.toMatch(/]+type=["']file|https?:\/\//i) + expect(app).toContain('client.request("preferences.update"') + expect(app).toContain('client.request("lifecycle.setAwake"') + }) +}) diff --git a/packages/plugins/convax-pet/surface-imports.test.js b/packages/plugins/convax-pet/surface-imports.test.js new file mode 100644 index 0000000..68847de --- /dev/null +++ b/packages/plugins/convax-pet/surface-imports.test.js @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" + +const packageRoot = new URL("./package/", import.meta.url) +const scanner = new Bun.Transpiler({ loader: "js" }) + +async function expectRelativeModuleGraph(entry) { + const pending = [entry] + const visited = new Set() + + while (pending.length) { + const moduleUrl = pending.pop() + if (visited.has(moduleUrl.href)) continue + visited.add(moduleUrl.href) + + const source = await fs.readFile(moduleUrl, "utf8") + for (const imported of scanner.scanImports(source)) { + expect(imported.path).toMatch(/^\.\.?\//) + const importedUrl = new URL(imported.path, moduleUrl) + expect(importedUrl.href.startsWith(packageRoot.href)).toBe(true) + pending.push(importedUrl) + } + } +} + +describe("Plugin-owned pet surface modules", () => { + test("uses browser-relative imports throughout the overlay graph", async () => { + await expectRelativeModuleGraph(new URL("./package/pet/app.js", import.meta.url)) + }) + + test("uses browser-relative imports throughout the settings graph", async () => { + await expectRelativeModuleGraph(new URL("./package/settings/app.js", import.meta.url)) + }) +}) diff --git a/registry/config.json b/registry/config.json index ba37dc6..32b5dae 100644 --- a/registry/config.json +++ b/registry/config.json @@ -1,4 +1,4 @@ { - "sequence": 29, + "sequence": 30, "yanked": [] } diff --git a/schemas/convax-plugin-manifest-v5.schema.json b/schemas/convax-plugin-manifest-v5.schema.json index 9bb21f8..e2eece6 100644 --- a/schemas/convax-plugin-manifest-v5.schema.json +++ b/schemas/convax-plugin-manifest-v5.schema.json @@ -15,7 +15,7 @@ "capabilities": { "type": "array", "uniqueItems": true, - "maxItems": 8, + "maxItems": 16, "items": { "enum": [ "canvas.connectedImages.read", @@ -25,7 +25,15 @@ "project.files.read", "agent.prompt", "generation.execute", - "ui.fullscreen" + "ui.fullscreen", + "projects.read", + "canvas.catalog.read", + "canvas.document.read", + "canvas.document.write", + "canvas.events.subscribe", + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write" ] } }, @@ -37,6 +45,7 @@ "canvas": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/canvas" }, "generation": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/generation" }, "llm": { "$ref": "#/$defs/llm" }, + "pet": { "$ref": "#/$defs/pet" }, "service": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/service" }, "skills": { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/skills" } } @@ -79,7 +88,21 @@ }, { "if": { - "properties": { "contributes": { "anyOf": [{ "required": ["generation"] }, { "required": ["llm"] }, { "required": ["service"] }] } }, + "properties": { "contributes": { "required": ["generation"] } }, + "required": ["contributes"] + }, + "then": { "required": ["runtime"] } + }, + { + "if": { + "properties": { "contributes": { "required": ["llm"] } }, + "required": ["contributes"] + }, + "then": { "required": ["runtime"] } + }, + { + "if": { + "properties": { "contributes": { "required": ["service"] } }, "required": ["contributes"] }, "then": { "required": ["runtime"] } @@ -117,13 +140,51 @@ } } } + }, + { + "if": { + "properties": { "contributes": { "required": ["pet"] } }, + "required": ["contributes"] + }, + "then": { + "required": ["capabilities"], + "properties": { + "capabilities": { + "minItems": 3, + "maxItems": 3, + "allOf": [ + { "contains": { "const": "pet.activity.read" } }, + { "contains": { "const": "pet.activity.open" } }, + { "contains": { "const": "pet.preferences.write" } } + ] + } + }, + "not": { "required": ["runtime"] } + } } ], "anyOf": [ { "required": ["runtime"] }, { "required": ["entry"] }, { - "properties": { "capabilities": { "contains": { "const": "generation.execute" } } }, + "properties": { "contributes": { "required": ["pet"] } }, + "required": ["contributes"] + }, + { + "properties": { + "capabilities": { + "contains": { + "enum": [ + "generation.execute", + "projects.read", + "canvas.catalog.read", + "canvas.document.read", + "canvas.document.write", + "canvas.events.subscribe" + ] + } + } + }, "required": ["capabilities"] } ], @@ -131,17 +192,8 @@ "llm": { "type": "object", "additionalProperties": false, - "required": ["provider", "models"], + "required": ["models", "provider"], "properties": { - "provider": { - "type": "object", - "additionalProperties": false, - "required": ["id", "name"], - "properties": { - "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 80 }, - "name": { "type": "string", "minLength": 1, "maxLength": 120 } - } - }, "models": { "type": "array", "minItems": 1, @@ -155,8 +207,43 @@ "name": { "type": "string", "minLength": 1, "maxLength": 120 } } } + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 80 }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 } + } } } + }, + "pet": { + "type": "object", + "additionalProperties": false, + "required": ["library", "overlay", "settings", "protocol"], + "properties": { + "library": { + "allOf": [ + { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, + { "pattern": "\\.json$" } + ] + }, + "overlay": { + "allOf": [ + { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, + { "pattern": "\\.html$" } + ] + }, + "settings": { + "allOf": [ + { "$ref": "./convax-plugin-manifest-v4.schema.json#/$defs/path" }, + { "pattern": "\\.html$" } + ] + }, + "protocol": { "const": "convax.pet-host/1" } + } } } } diff --git a/tooling/lib.mjs b/tooling/lib.mjs index 48602fe..104e2df 100644 --- a/tooling/lib.mjs +++ b/tooling/lib.mjs @@ -28,6 +28,24 @@ const pluginCapabilities = new Set([ "agent.prompt", "generation.execute", "ui.fullscreen", + "projects.read", + "canvas.catalog.read", + "canvas.document.read", + "canvas.document.write", + "canvas.events.subscribe", + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write", +]) +const pluginV5Capabilities = new Set([ + "projects.read", + "canvas.catalog.read", + "canvas.document.read", + "canvas.document.write", + "canvas.events.subscribe", + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write", ]) const generationModalities = new Set(["text", "image", "video", "audio"]) const generationInputRoles = new Set([ @@ -146,7 +164,7 @@ function parseCompatibility(value, kind, label) { const v4 = value.pluginSchema === "convax.plugin/4" && value.pluginHost === "convax.plugin-host/4" const v5 = value.pluginSchema === "convax.plugin/5" && value.pluginHost === "convax.plugin-capability/1" if (!v1 && !v2 && !v3 && !v4 && !v5) { - error(label, "must pair matching convax.plugin and convax.plugin-host versions, or convax.plugin/5 with convax.plugin-capability/1") + error(label, "must pair matching convax.plugin and convax.plugin-host versions 1-4, or convax.plugin/5 with convax.plugin-capability/1") } return { pluginSchema: value.pluginSchema, pluginHost: value.pluginHost } } @@ -230,7 +248,7 @@ export function parseSourceMetadata(value, label = "convax-package.json") { if (companions && compatibility.pluginSchema !== "convax.plugin/2" && compatibility.pluginSchema !== "convax.plugin/3" && compatibility.pluginSchema !== "convax.plugin/4" && compatibility.pluginSchema !== "convax.plugin/5") { - error(label, "companions require convax.plugin/2 through convax.plugin/5 compatibility") + error(label, "companions require convax.plugin/2 or later compatibility") } return { schema: "convax.package/1", @@ -322,6 +340,9 @@ function parseLegacyPluginManifest(value, label) { if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size || capabilities.some((item) => typeof item !== "string" || !pluginCapabilities.has(item)) || new Set(capabilities).size !== capabilities.length) error(label, "invalid or duplicate capability") + if (capabilities.some((capability) => pluginV5Capabilities.has(capability))) { + error(label, "Project-wide Canvas capabilities are available only to convax.plugin/5") + } if (!v2 && capabilities.includes("generation.execute")) { error(label, "generation.execute is available only to convax.plugin/2") } @@ -528,6 +549,9 @@ function parsePluginManifestV3(value, label) { if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size || capabilities.some((item) => typeof item !== "string" || !pluginCapabilities.has(item)) || new Set(capabilities).size !== capabilities.length) error(label, "invalid or duplicate capability") + if (capabilities.some((capability) => pluginV5Capabilities.has(capability))) { + error(label, "Project-wide Canvas capabilities are available only to convax.plugin/5") + } const hasRuntime = value.runtime !== undefined const hasGeneration = value.contributes.generation !== undefined @@ -611,6 +635,81 @@ function parseOwnedSkillsV4(value, label) { return skills } +function parsePluginManifestV4(value, label) { + const required = ["contributes", "description", "id", "name", "schema", "version"] + exactKeys(value, + ["capabilities", "contributes", "description", "entry", "id", "name", "runtime", "schema", "version"], + required, label) + exactKeys(value.contributes, ["agent", "canvas", "generation", "service", "skills"], [], `${label} contributes`) + + const capabilities = value.capabilities ?? [] + if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size || + capabilities.some((item) => typeof item !== "string" || !pluginCapabilities.has(item)) || + new Set(capabilities).size !== capabilities.length) error(label, "invalid or duplicate capability") + if (capabilities.some((capability) => pluginV5Capabilities.has(capability))) { + error(label, "Project-wide Canvas capabilities are available only to convax.plugin/5") + } + + const hasRuntime = value.runtime !== undefined + const hasGeneration = value.contributes.generation !== undefined + const hasService = value.contributes.service !== undefined + if (hasRuntime !== (hasGeneration || hasService)) { + error(label, "runtime and executable contribution must appear together") + } + + const generation = hasGeneration ? parseGenerationV3(value.contributes.generation, `${label} generation`) : undefined + if (value.contributes.agent !== undefined && generation === undefined) { + error(label, "agent tools require a generation contribution") + } + const agent = value.contributes.agent === undefined + ? undefined + : parseAgentV3(value.contributes.agent, generation, `${label} agent`) + + const hasCanvas = value.contributes.canvas !== undefined + if (hasCanvas && value.contributes.canvas.selectionActions !== undefined && generation === undefined) { + error(label, "selectionActions require a generation contribution") + } + const canvas = hasCanvas + ? parseCanvasV3(value.contributes.canvas, generation, `${label} canvas`) + : undefined + const hasRenderer = canvas?.renderer !== undefined + if (!hasRuntime && !hasRenderer && !capabilities.includes("generation.execute")) { + error(label, "convax.plugin/4 must declare a Plugin capability beyond owned Skills") + } + const hasEntry = value.entry !== undefined + if (hasEntry !== hasRenderer) error(label, "entry and Canvas renderer must appear together") + if (capabilities.includes("generation.execute") && !hasRenderer) { + error(label, "generation.execute requires a sandboxed Canvas renderer") + } + + let entry + if (hasEntry) { + entry = parseRelativePath(value.entry, `${label} entry`) + if (!entry.toLowerCase().endsWith(".html")) error(label, "entry must be an HTML file") + } + + const service = hasService ? parseService(value.contributes.service, `${label} service`) : undefined + const skills = parseOwnedSkillsV4(value.contributes.skills, `${label} skills`) + const runtime = hasRuntime ? parseMcpStdioRuntime(value.runtime, `${label} runtime`) : undefined + return { + capabilities: [...capabilities], + contributes: { + ...(agent === undefined ? {} : { agent }), + ...(canvas === undefined ? {} : { canvas }), + ...(generation === undefined ? {} : { generation }), + ...(service === undefined ? {} : { service }), + ...(skills === undefined ? {} : { skills }), + }, + description: cleanString(value.description, `${label} description`, 2000), + ...(entry === undefined ? {} : { entry }), + id: parseId(value.id, `${label} id`), + name: cleanString(value.name, `${label} name`, 120), + schema: "convax.plugin/4", + ...(runtime === undefined ? {} : { runtime }), + version: parseSemver(value.version, `${label} version`), + } +} + function parseLlmV5(value, label) { exactKeys(value, ["models", "provider"], ["models", "provider"], label) exactKeys(value.provider, ["id", "name"], ["id", "name"], `${label} provider`) @@ -622,31 +721,52 @@ function parseLlmV5(value, label) { const itemLabel = `${label} model ${index}` exactKeys(item, ["id", "name"], ["id", "name"], itemLabel) const id = cleanString(item.id, `${itemLabel} id`, 128) - if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(id)) error(itemLabel, "id is invalid") + if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(id)) error(itemLabel, "invalid id") return { id, name: cleanString(item.name, `${itemLabel} name`, 120) } }) if (new Set(models.map((model) => model.id)).size !== models.length) error(label, "models contain duplicate ids") - return { models, provider: { id: providerId, name: cleanString(value.provider.name, `${label} provider name`, 120) } } + return { + models, + provider: { id: providerId, name: cleanString(value.provider.name, `${label} provider name`, 120) }, + } +} + +function parsePetV5(value, label) { + exactKeys(value, ["library", "overlay", "protocol", "settings"], ["library", "overlay", "protocol", "settings"], label) + const library = parseRelativePath(value.library, `${label} library`) + const overlay = parseRelativePath(value.overlay, `${label} overlay`) + const settings = parseRelativePath(value.settings, `${label} settings`) + if (!library.toLowerCase().endsWith(".json")) error(label, "library must be a JSON file") + if (!overlay.toLowerCase().endsWith(".html")) error(label, "overlay must be an HTML file") + if (!settings.toLowerCase().endsWith(".html")) error(label, "settings must be an HTML file") + if (value.protocol !== "convax.pet-host/1") error(label, "protocol must equal convax.pet-host/1") + return { library, overlay, protocol: "convax.pet-host/1", settings } } -function parsePluginManifestV4(value, label, schema = "convax.plugin/4") { +function parsePluginManifestV5(value, label) { const required = ["contributes", "description", "id", "name", "schema", "version"] - exactKeys(value, + exactKeys( + value, ["capabilities", "contributes", "description", "entry", "id", "name", "runtime", "schema", "version"], - required, label) - exactKeys(value.contributes, - ["agent", "canvas", "generation", ...(schema === "convax.plugin/5" ? ["llm"] : []), "service", "skills"], - [], `${label} contributes`) + required, + label, + ) + exactKeys( + value.contributes, + ["agent", "canvas", "generation", "llm", "pet", "service", "skills"], + [], + `${label} contributes`, + ) const capabilities = value.capabilities ?? [] if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size || capabilities.some((item) => typeof item !== "string" || !pluginCapabilities.has(item)) || new Set(capabilities).size !== capabilities.length) error(label, "invalid or duplicate capability") - const hasRuntime = value.runtime !== undefined const hasGeneration = value.contributes.generation !== undefined const hasService = value.contributes.service !== undefined const hasLlm = value.contributes.llm !== undefined + const hasRuntime = value.runtime !== undefined if (hasRuntime !== (hasGeneration || hasService || hasLlm)) { error(label, "runtime and executable contribution must appear together") } @@ -667,24 +787,37 @@ function parsePluginManifestV4(value, label, schema = "convax.plugin/4") { ? parseCanvasV3(value.contributes.canvas, generation, `${label} canvas`) : undefined const hasRenderer = canvas?.renderer !== undefined - if (!hasRuntime && !hasRenderer && !capabilities.includes("generation.execute")) { - error(label, `${schema} must declare a Plugin capability beyond owned Skills`) - } const hasEntry = value.entry !== undefined if (hasEntry !== hasRenderer) error(label, "entry and Canvas renderer must appear together") if (capabilities.includes("generation.execute") && !hasRenderer) { error(label, "generation.execute requires a sandboxed Canvas renderer") } + const skills = parseOwnedSkillsV4(value.contributes.skills, `${label} skills`) + const pet = value.contributes.pet === undefined + ? undefined + : parsePetV5(value.contributes.pet, `${label} pet`) + if (pet !== undefined) { + const requiredPetCapabilities = ["pet.activity.read", "pet.activity.open", "pet.preferences.write"] + if (capabilities.length !== requiredPetCapabilities.length || + requiredPetCapabilities.some((capability) => !capabilities.includes(capability))) { + error(label, "pet capabilities must be exactly pet.activity.read, pet.activity.open, and pet.preferences.write") + } + if (hasRuntime) error(label, "pet feature cannot declare an executable runtime") + } + const hasProjectCapability = capabilities.some((capability) => pluginV5Capabilities.has(capability)) + if (!hasRuntime && !hasRenderer && !canvas?.selectionActions?.length && + !capabilities.includes("generation.execute") && !hasProjectCapability && pet === undefined) { + error(label, "convax.plugin/5 must declare a Plugin capability beyond owned Skills") + } + let entry if (hasEntry) { entry = parseRelativePath(value.entry, `${label} entry`) if (!entry.toLowerCase().endsWith(".html")) error(label, "entry must be an HTML file") } - - const service = hasService ? parseService(value.contributes.service, `${label} service`) : undefined const llm = hasLlm ? parseLlmV5(value.contributes.llm, `${label} llm`) : undefined - const skills = parseOwnedSkillsV4(value.contributes.skills, `${label} skills`) + const service = hasService ? parseService(value.contributes.service, `${label} service`) : undefined const runtime = hasRuntime ? parseMcpStdioRuntime(value.runtime, `${label} runtime`) : undefined return { capabilities: [...capabilities], @@ -693,14 +826,15 @@ function parsePluginManifestV4(value, label, schema = "convax.plugin/4") { ...(canvas === undefined ? {} : { canvas }), ...(generation === undefined ? {} : { generation }), ...(llm === undefined ? {} : { llm }), + ...(pet === undefined ? {} : { pet }), ...(service === undefined ? {} : { service }), ...(skills === undefined ? {} : { skills }), }, - description: cleanString(value.description, `${label} description`, 2000), + description: cleanString(value.description, `${label} description`, 2_000), ...(entry === undefined ? {} : { entry }), id: parseId(value.id, `${label} id`), name: cleanString(value.name, `${label} name`, 120), - schema, + schema: "convax.plugin/5", ...(runtime === undefined ? {} : { runtime }), version: parseSemver(value.version, `${label} version`), } @@ -713,7 +847,7 @@ export function parsePluginManifest(value, label = "manifest.json") { value.schema !== "convax.plugin/5")) { error(label, "unsupported schema") } - if (value.schema === "convax.plugin/5") return parsePluginManifestV4(value, label, "convax.plugin/5") + if (value.schema === "convax.plugin/5") return parsePluginManifestV5(value, label) if (value.schema === "convax.plugin/4") return parsePluginManifestV4(value, label) if (value.schema === "convax.plugin/3") return parsePluginManifestV3(value, label) return parseLegacyPluginManifest(value, label) @@ -958,6 +1092,70 @@ function webpDimensions(data, label) { error(label, "unsupported or malformed WebP bitstream") } +function parsePetLibrary(value, label) { + exactKeys(value, ["pets", "schema"], ["pets", "schema"], label) + if (value.schema !== "convax.pet-library/1") error(label, "schema must equal convax.pet-library/1") + if (!Array.isArray(value.pets) || value.pets.length < 1 || value.pets.length > 64) { + error(label, "pets must contain between 1 and 64 entries") + } + const pets = value.pets.map((item, index) => { + const itemLabel = `${label} pets[${index}]` + exactKeys( + item, + ["alt", "description", "displayName", "id", "spritesheet", "spriteVersion"], + ["alt", "description", "displayName", "id", "spritesheet", "spriteVersion"], + itemLabel, + ) + const spritesheet = parseRelativePath(item.spritesheet, `${itemLabel} spritesheet`) + if (!/\.(?:png|webp)$/i.test(spritesheet)) error(itemLabel, "spritesheet must be a PNG or WebP file") + if (item.spriteVersion !== 2) error(itemLabel, "spriteVersion must equal 2") + return { + alt: cleanString(item.alt, `${itemLabel} alt`, 500), + description: cleanString(item.description, `${itemLabel} description`, 2_000), + displayName: cleanString(item.displayName, `${itemLabel} displayName`, 120), + id: parseId(item.id, `${itemLabel} id`), + spritesheet, + spriteVersion: 2, + } + }) + if (new Set(pets.map((pet) => pet.id)).size !== pets.length) error(label, "pets contain duplicate ids") + if (new Set(pets.map((pet) => pet.spritesheet.toLocaleLowerCase("en-US"))).size !== pets.length) { + error(label, "pets contain duplicate spritesheet paths") + } + return { schema: "convax.pet-library/1", pets } +} + +export function validatePetPackageLibrary(manifest, files, label = "Plugin") { + const pet = manifest.contributes?.pet + if (!pet) return undefined + const entries = new Map(files.map((file) => [file.relativePath, file])) + for (const [field, kind] of [["overlay", "overlay"], ["settings", "settings"]]) { + if (!entries.has(pet[field])) error(label, `missing declared pet ${kind} ${pet[field]}`) + } + const libraryFile = entries.get(pet.library) + if (!libraryFile) error(label, `missing declared pet library ${pet.library}`) + let libraryValue + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(libraryFile.data) + libraryValue = JSON.parse(text) + } catch (cause) { + throw new Error(`${label} pet library: invalid UTF-8 JSON`, { cause }) + } + const library = parsePetLibrary(libraryValue, `${label} pet library`) + for (const petEntry of library.pets) { + const asset = entries.get(petEntry.spritesheet) + if (!asset) error(label, `missing declared pet spritesheet ${petEntry.spritesheet}`) + const extension = path.posix.extname(petEntry.spritesheet).toLowerCase() + const dimensions = extension === ".png" + ? pngDimensions(asset.data, `${label} pet spritesheet ${petEntry.id}`) + : webpDimensions(asset.data, `${label} pet spritesheet ${petEntry.id}`) + if (dimensions.width !== 1536 || dimensions.height !== 1872) { + error(label, `pet spritesheet ${petEntry.id} must be exactly 1536 by 1872 pixels`) + } + } + return library +} + function mp4Dimensions(data, label) { if (data.length < 24 || data.toString("ascii", 4, 8) !== "ftyp") error(label, "content is not an MP4 file") for (let offset = 4; offset + 4 <= data.length; offset += 1) { @@ -1102,6 +1300,7 @@ export async function discoverPackages(options = {}) { } const names = new Set(files.map((file) => file.relativePath)) if (manifest.entry && !names.has(manifest.entry)) error(`${candidate.kind}/${candidate.id}`, `missing entry ${manifest.entry}`) + validatePetPackageLibrary(manifest, files, `${candidate.kind}/${candidate.id}`) if (manifest.runtime && names.has(manifest.runtime.command)) { error(`${candidate.kind}/${candidate.id}`, "external runtime executable must not be included in the Plugin ZIP") } @@ -1557,7 +1756,7 @@ function parseRegistryCompanions(value, metadata, manifest, label) { if (value === undefined) return undefined if ((manifest.schema !== "convax.plugin/2" && manifest.schema !== "convax.plugin/3" && manifest.schema !== "convax.plugin/4" && manifest.schema !== "convax.plugin/5") || !manifest.runtime) { - error(label, "companions require a convax.plugin/2 through convax.plugin/5 external runtime") + error(label, "companions require a convax.plugin/2 or later external runtime") } if (!Array.isArray(value) || value.length < 1 || value.length > 16) { error(label, "must be a non-empty array with at most 16 items") diff --git a/tooling/plugin-v5.test.js b/tooling/plugin-v5.test.js index 76765c0..1b967d8 100644 --- a/tooling/plugin-v5.test.js +++ b/tooling/plugin-v5.test.js @@ -1,63 +1,265 @@ import { describe, expect, test } from "bun:test" -import { parsePluginManifest, parseSourceMetadata } from "./lib.mjs" -function manifest(overrides = {}) { +import { parsePluginManifest, parseSourceMetadata, validatePetPackageLibrary } from "./lib.mjs" + +function webp(width, height) { + const data = Buffer.alloc(30) + data.write("RIFF", 0) + data.write("WEBP", 8) + data.write("VP8X", 12) + data.writeUIntLE(width - 1, 24, 3) + data.writeUIntLE(height - 1, 27, 3) + return data +} + +function png(width, height) { + const data = Buffer.alloc(24) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data) + data.write("IHDR", 12) + data.writeUInt32BE(width, 16) + data.writeUInt32BE(height, 20) + return data +} + +function petLibrary(pets = [{ + id: "violet", + displayName: "Violet", + description: "A pixel companion for Convax.", + spritesheet: "assets/violet.webp", + spriteVersion: 2, + alt: "Violet, the Convax pixel companion", +}]) { + return { schema: "convax.pet-library/1", pets } +} + +function packageFile(relativePath, data) { + return { relativePath, data: Buffer.isBuffer(data) ? data : Buffer.from(data) } +} + +function petSurfaceFiles() { + return [packageFile("pet/index.html", ""), packageFile("settings/index.html", "")] +} + +function petManifest(overrides = {}) { return { + schema: "convax.plugin/5", + id: "convax-pet", + name: "Convax Pet", + description: "A local desktop companion and pet library.", + version: "0.2.0", + capabilities: ["pet.activity.read", "pet.activity.open", "pet.preferences.write"], + contributes: { + pet: { + library: "pet-library.json", + overlay: "pet/index.html", + settings: "settings/index.html", + protocol: "convax.pet-host/1", + }, + }, + ...overrides, + } +} + +function projectManifest(overrides = {}) { + return { + schema: "convax.plugin/5", + id: "canvas-automation", + name: "Canvas Automation", + description: "Automates bound Project Canvases.", + version: "1.0.0", + capabilities: [ + "projects.read", + "canvas.catalog.read", + "canvas.document.read", + "canvas.document.write", + "canvas.events.subscribe", + ], + contributes: {}, + ...overrides, + } +} + +function llmManifest(overrides = {}) { + return { + schema: "convax.plugin/5", + id: "example-llm", + name: "Example LLM", + description: "Provides an external LLM provider.", + version: "1.0.0", capabilities: [], contributes: { llm: { - models: [{ id: "pippit-glm-main", name: "Pippit GLM Main" }], - provider: { id: "pippit-glm", name: "Pippit GLM" }, + models: [{ id: "example-main", name: "Example Main" }], + provider: { id: "example", name: "Example" }, }, }, - description: "External LLM provider", - id: "xiaoyunque-generation", - name: "XiaoYunque", - runtime: { command: "convax-xiaoyunque-mcp", type: "mcp-stdio" }, - schema: "convax.plugin/5", - version: "0.4.0", + runtime: { command: "example-llm-mcp", type: "mcp-stdio" }, ...overrides, } } -describe("convax.plugin/5 LLM contributions", () => { - test("parses bounded provider and model display metadata", () => { - expect(parsePluginManifest(manifest()).contributes.llm).toEqual({ - models: [{ id: "pippit-glm-main", name: "Pippit GLM Main" }], - provider: { id: "pippit-glm", name: "Pippit GLM" }, +describe("convax.plugin/5 transport-neutral and pet contributions", () => { + test("parses a sandboxed pet feature as a real Plugin capability", () => { + const parsed = parsePluginManifest(petManifest()) + + expect(parsed.schema).toBe("convax.plugin/5") + expect(parsed.contributes.pet).toEqual({ + library: "pet-library.json", + overlay: "pet/index.html", + protocol: "convax.pet-host/1", + settings: "settings/index.html", + }) + expect(parsed.capabilities).toEqual(["pet.activity.read", "pet.activity.open", "pet.preferences.write"]) + expect(parsed).not.toHaveProperty("entry") + expect(parsed).not.toHaveProperty("runtime") + }) + + test("retains the existing v5 Project, Canvas, and LLM declarations", () => { + expect(parsePluginManifest(projectManifest())).toMatchObject({ + capabilities: projectManifest().capabilities, + contributes: {}, + schema: "convax.plugin/5", + }) + expect(parsePluginManifest(llmManifest()).contributes.llm).toEqual({ + models: [{ id: "example-main", name: "Example Main" }], + provider: { id: "example", name: "Example" }, }) }) - test("keeps endpoints, credentials, and headers out of the manifest", () => { + test("keeps endpoints, credentials, and headers out of LLM manifests", () => { for (const field of ["apiKey", "baseUrl", "headers"]) { - expect(() => parsePluginManifest(manifest({ - contributes: { llm: { ...manifest().contributes.llm, [field]: "private" } }, - }))).toThrow("unsupported field") + expect(() => + parsePluginManifest( + llmManifest({ + contributes: { llm: { ...llmManifest().contributes.llm, [field]: "private" } }, + }), + ), + ).toThrow("unsupported field") } }) - test("requires the v5 capability compatibility broker", () => { + test("accepts only the transport-neutral v5 compatibility pair", () => { const metadata = { - companions: [{ - command: "convax-xiaoyunque-mcp", - source: "packages/tools/xiaoyunque-mcp", - targets: [{ arch: "arm64", path: "dist/darwin-arm64/convax-xiaoyunque-mcp", platform: "darwin" }], - version: "0.4.0", - }], - compatibility: { pluginHost: "convax.plugin-capability/1", pluginSchema: "convax.plugin/5" }, - description: "External LLM provider", - id: "xiaoyunque-generation", + schema: "convax.package/1", kind: "plugin", + id: "convax-pet", + name: "Convax Pet", + description: "Adds Violet as a local desktop companion.", + version: "0.1.0", license: "MIT", - name: "XiaoYunque", - schema: "convax.package/1", - version: "0.4.0", + compatibility: { + pluginSchema: "convax.plugin/5", + pluginHost: "convax.plugin-capability/1", + }, yanked: false, } + expect(parseSourceMetadata(metadata).compatibility).toEqual(metadata.compatibility) - expect(() => parseSourceMetadata({ - ...metadata, - compatibility: { pluginHost: "convax.plugin-host/4", pluginSchema: "convax.plugin/5" }, - })).toThrow("matching") + expect(() => + parseSourceMetadata({ + ...metadata, + compatibility: { pluginSchema: "convax.plugin/5", pluginHost: "convax.plugin-host/5" }, + }), + ).toThrow("matching") + }) + + test.each([ + ["remote URL", { overlay: "https://example.invalid/pet.html" }], + ["traversal", { settings: "../settings.html" }], + ["wrong library extension", { library: "pet-library.js" }], + ["wrong overlay extension", { overlay: "pet/app.js" }], + ["wrong settings extension", { settings: "settings/app.js" }], + ["unsupported protocol", { protocol: "convax.pet-host/2" }], + ["unknown field", { mood: "happy" }], + ])("rejects a pet with %s", (_label, override) => { + const manifest = petManifest() + manifest.contributes.pet = { ...manifest.contributes.pet, ...override } + expect(() => parsePluginManifest(manifest)).toThrow() + }) + + test("does not make pet available to legacy manifest schemas", () => { + expect(() => parsePluginManifest({ ...petManifest(), schema: "convax.plugin/4" })).toThrow("unsupported field pet") + }) + + test("requires the exact pet capabilities and forbids executable runtimes", () => { + expect(() => parsePluginManifest({ ...petManifest(), capabilities: [] })).toThrow("pet capabilities") + expect(() => + parsePluginManifest({ + ...petManifest(), + contributes: { ...petManifest().contributes, llm: llmManifest().contributes.llm }, + runtime: { command: "pet-runtime", type: "mcp-stdio" }, + }), + ).toThrow("pet feature") + }) + + test("validates every packaged pet library atlas", () => { + const manifest = parsePluginManifest(petManifest()) + const comet = { + ...petLibrary().pets[0], + id: "comet", + displayName: "Comet", + spritesheet: "assets/comet.png", + } + const library = petLibrary([...petLibrary().pets, comet]) + const files = [ + ...petSurfaceFiles(), + packageFile("pet-library.json", JSON.stringify(library)), + packageFile("assets/violet.webp", webp(1536, 1872)), + packageFile("assets/comet.png", png(1536, 1872)), + ] + + expect(validatePetPackageLibrary(manifest, files, "pet test")).toEqual(library) + }) + + test.each([ + ["missing library", petSurfaceFiles()], + ["invalid JSON", [...petSurfaceFiles(), packageFile("pet-library.json", "{")]], + ["empty library", [...petSurfaceFiles(), packageFile("pet-library.json", JSON.stringify(petLibrary([])))]], + [ + "duplicate id", + [ + ...petSurfaceFiles(), + packageFile("pet-library.json", JSON.stringify(petLibrary([...petLibrary().pets, petLibrary().pets[0]]))), + ], + ], + [ + "case-colliding atlas paths", + [ + ...petSurfaceFiles(), + packageFile( + "pet-library.json", + JSON.stringify( + petLibrary([ + ...petLibrary().pets, + { ...petLibrary().pets[0], id: "comet", spritesheet: "assets/VIOLET.WEBP" }, + ]), + ), + ), + packageFile("assets/violet.webp", webp(1536, 1872)), + packageFile("assets/VIOLET.WEBP", webp(1536, 1872)), + ], + ], + [ + "missing atlas", + [...petSurfaceFiles(), packageFile("pet-library.json", JSON.stringify(petLibrary()))], + ], + [ + "forged atlas", + [ + ...petSurfaceFiles(), + packageFile("pet-library.json", JSON.stringify(petLibrary())), + packageFile("assets/violet.webp", "not an image"), + ], + ], + [ + "wrong dimensions", + [ + ...petSurfaceFiles(), + packageFile("pet-library.json", JSON.stringify(petLibrary())), + packageFile("assets/violet.webp", webp(1, 1)), + ], + ], + ])("rejects a pet package with %s", (_label, files) => { + expect(() => validatePetPackageLibrary(parsePluginManifest(petManifest()), files, "pet test")).toThrow() }) }) diff --git a/tooling/registry.test.js b/tooling/registry.test.js index 4bdd94d..b9046c6 100644 --- a/tooling/registry.test.js +++ b/tooling/registry.test.js @@ -34,6 +34,7 @@ describe("source packages", () => { const packages = await discoverPackages() expect(packages.map((pkg) => `${pkg.metadata.kind}/${pkg.metadata.id}`)).toEqual([ "plugin/codex-service", + "plugin/convax-pet", "plugin/ffmpeg-tools", "plugin/hello-convax", "plugin/multi-angle", @@ -53,6 +54,7 @@ describe("source packages", () => { "skill/skill-reviewer", "skill/video-prompting", ]) + const violet = packages.find((pkg) => pkg.metadata.id === "convax-pet") const ffmpeg = packages.find((pkg) => pkg.metadata.id === "ffmpeg-tools") const ffmpegSkill = packages.find((pkg) => pkg.metadata.kind === "skill" && pkg.metadata.id === "ffmpeg-canvas") const hello = packages.find((pkg) => pkg.metadata.id === "hello-convax") @@ -60,6 +62,28 @@ describe("source packages", () => { const multiAngle = packages.find((pkg) => pkg.metadata.id === "multi-angle") const panorama = packages.find((pkg) => pkg.metadata.id === "panorama-viewer") const xiaoyunque = packages.find((pkg) => pkg.metadata.id === "xiaoyunque-generation") + expect(violet.metadata.version).toBe("0.2.1") + expect(violet.manifest.capabilities).toEqual([ + "pet.activity.read", + "pet.activity.open", + "pet.preferences.write", + ]) + expect(violet.manifest.contributes.pet).toEqual({ + library: "pet-library.json", + overlay: "pet/index.html", + protocol: "convax.pet-host/1", + settings: "settings/index.html", + }) + expect(violet.manifest).not.toHaveProperty("entry") + expect(violet.manifest).not.toHaveProperty("runtime") + expect(violet.files.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + "assets/violet.png", + "pet-library.json", + "pet/index.html", + "settings/index.html", + ]), + ) expect(hello.manifest.schema).toBe("convax.plugin/1") expect(hello.manifest.capabilities).toEqual([]) expect(codex.manifest).toEqual(expect.objectContaining({ diff --git a/tooling/workspaces.test.js b/tooling/workspaces.test.js index b69eaaa..4bfb712 100644 --- a/tooling/workspaces.test.js +++ b/tooling/workspaces.test.js @@ -41,6 +41,49 @@ describe("Bun workspace ownership", () => { expect(Object.keys(lock.workspaces)).toContain("packages/skills/ffmpeg-canvas") }) + test("keeps workspace versions synchronized with the root lockfile", async () => { + const lock = await readJson(path.join(root, "bun.lock")) + + for (const collection of collections) { + const directory = path.join(root, "packages", collection) + const entries = await fs.readdir(directory, { withFileTypes: true }) + for (const entry of entries.filter((item) => item.isDirectory())) { + const workspacePath = path.posix.join("packages", collection, entry.name) + const workspace = await readJson(path.join(directory, entry.name, "package.json")) + expect(lock.workspaces[workspacePath]?.version).toBe(workspace.version) + } + } + }) + + test("documents the public v5 pet package contract", async () => { + expect((await fs.stat(path.join(root, "schemas", "convax-plugin-manifest-v5.schema.json"))).isFile()).toBe(true) + + const packageReadme = await fs.readFile( + path.join(root, "packages", "plugins", "convax-pet", "package", "README.md"), + "utf8", + ) + expect(packageReadme).toContain("contributes.pet") + expect(packageReadme).toContain("convax.pet-library/1") + expect(packageReadme).toContain("convax.pet-host/1") + expect(packageReadme).toContain("1536×1872") + expect(packageReadme).toContain("feature Plugin") + + const documentation = await Promise.all([ + "README.md", + "README.zh-CN.md", + "docs/plugin-authoring.md", + "docs/packaging.md", + "docs/registry-spec.md", + ].map((file) => fs.readFile(path.join(root, file), "utf8"))) + for (const text of documentation) { + expect(text).toContain("convax.plugin/5") + expect(text).toContain("contributes.pet") + } + expect(documentation[2]).toContain("convax.plugin-capability/1") + expect(documentation[2]).toContain("convax.pet-host/1") + expect(documentation[2]).toContain("one Pet feature Plugin") + }) + test("runs package builds in dependency order before repository validation and packing", async () => { const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "convax-workspace-build-")) try {