Releasing version 2

This commit is contained in:
Alvin Unreal 2026-05-10 13:30:48 +02:00
commit 33b0c22068
162 changed files with 29761 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
v1
web
node_modules
dist
dist-electron
apps/desktop/dist-electron
*.tsbuildinfo
.DS_Store

2
.ignore Normal file
View file

@ -0,0 +1,2 @@
!v1
!web

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OpenPets
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

66
README.md Normal file
View file

@ -0,0 +1,66 @@
# OpenPets 2.0
OpenPets 2.0 is a tray-first desktop companion app for coding agents.
This repository contains the fresh v2 workspace. The `v1/` folder is reference material only, and the existing `web/` app remains outside the v2 workspace.
## Development baseline
- Node.js 20+
- pnpm 11+
- TypeScript
- No Bun runtime requirement for v2
## Install
```bash
pnpm install
```
## Checks
```bash
pnpm check
pnpm typecheck
pnpm build
```
## Desktop development
```bash
pnpm --filter @open-pets/desktop dev
```
Phase 01 desktop behavior is tray/menu-bar first. It should not open a dashboard window on startup.
## Workspace layout
```text
apps/desktop Electron desktop app shell
packages/client @open-pets/client, local IPC client
packages/mcp @open-pets/mcp, MCP stdio server
packages/claude @open-pets/claude, Claude integration package
packages/opencode @open-pets/opencode, OpenCode config/plugin integration
packages/agent-events shared safe agent event speech helpers
packages/cli @open-pets/cli, explicit user-run CLI commands
packages/pet-format @open-pets/pet-format, pet/catalog format types
```
## Agent integrations
- Claude Code: configure from Desktop Integrations or with `openpets configure --agent claude --pet <id>`.
- OpenCode project-local setup: use `openpets configure --agent opencode --pet <id>` from a project. This writes `.opencode/opencode.jsonc` when no OpenCode config exists, adds `.opencode/openpets.md`, and configures OpenPets MCP plus the OpenCode plugin for that project.
- OpenCode desktop setup: use Desktop Integrations → OpenCode for global setup. This writes OpenCode's global config directory only after explicit user action, adds a global `openpets.md` instruction file, and can be removed from the same screen. The MCP command uses bundled desktop CLI resources in packaged builds; the OpenCode plugin spec remains the published/version-pinned `@open-pets/opencode` package, so OpenCode may need npm/network access unless that package is already cached/installed.
OpenPets MCP tools remain `openpets_status`, `openpets_say`, and `openpets_react`. Speech must stay short and must not include code, logs, secrets, URLs, or file paths.
To reconfigure, rerun the CLI command for a project or use Replace/Install in Desktop Integrations for global OpenCode setup. To remove global desktop OpenCode setup, use the OpenCode Remove action; unrelated OpenCode config and user text outside OpenPets managed markers are preserved.
The v2 workspace intentionally includes only:
```text
apps/*
packages/*
```
It does not include `v1/**` or `web/**`.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 424 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Cursor">
<path fill="#0B0B0F" fill-rule="evenodd" d="M117.9 30.289L66.664.713a5.32 5.32 0 0 0-5.323 0L10.09 30.29a4.48 4.48 0 0 0-2.234 3.872v59.663c0 1.6.853 3.077 2.24 3.878l51.24 29.586a5.33 5.33 0 0 0 5.324 0l51.246-29.586a4.48 4.48 0 0 0 2.24-3.878V34.166a4.48 4.48 0 0 0-2.24-3.872zm-3.216 6.272l-49.47 85.681c-.337.576-1.217.341-1.217-.325V65.81a3.15 3.15 0 0 0-1.573-2.72l-48.59-28.055c-.571-.331-.336-1.216.33-1.216h98.94c1.409 0 2.284 1.525 1.58 2.741" />
</svg>

After

Width:  |  Height:  |  Size: 560 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>

After

Width:  |  Height:  |  Size: 235 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="VS Code">
<g fill="none" stroke="#007ACC" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path fill="#007ACC" d="M16 3v18l4-2.5v-13zM9.165 13.903L5 17.5l-2-1L7.333 12m1.735-1.802L16 3v5l-4.795 4.141" />
<path fill="#007ACC" d="M16 16.5L5 6.5l-2 1L16 21" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Windsurf">
<path fill="#0B0B0F" d="M23.55 5.067a2.177 2.177 0 0 0-2.18 2.177v4.867a1.77 1.77 0 0 1-1.76 1.76a1.82 1.82 0 0 1-1.472-.766l-4.971-7.1a2.2 2.2 0 0 0-1.81-.942c-1.134 0-2.154.964-2.154 2.153v4.896c0 .972-.797 1.76-1.76 1.76c-.57 0-1.136-.287-1.472-.766L.408 5.16A.224.224 0 0 0 0 5.288v4.245c0 .215.066.423.188.6l5.475 7.818c.324.462.8.805 1.351.93a2.164 2.164 0 0 0 2.645-2.098V11.89c0-.972.787-1.76 1.76-1.76h.002a1.8 1.8 0 0 1 1.472.766l4.972 7.1a2.172 2.172 0 0 0 3.96-1.212v-4.895a1.76 1.76 0 0 1 1.76-1.76h.195a.22.22 0 0 0 .22-.22V5.287a.22.22 0 0 0-.22-.22Z" />
</svg>

After

Width:  |  Height:  |  Size: 673 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Zed">
<path fill="#084ccf" d="M12 8a4 4 0 0 0-4 4v88H0V12C0 5.373 5.373 0 12 0h107.172c5.345 0 8.022 6.463 4.242 10.243L57.407 76.25H76V68h8v10.25a6 6 0 0 1-6 6H49.407L35.657 98H98V48h8v50a8 8 0 0 1-8 8H27.657l-14 14H116a4 4 0 0 0 4-4V28h8v88c0 6.627-5.373 12-12 12H8.828c-5.345 0-8.022-6.463-4.242-10.243L70.343 52H52v8h-8V50a6 6 0 0 1 6-6h28.343l14-14H30v50h-8V30a8 8 0 0 1 8-8h70.343l14-14Z" />
</svg>

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,41 @@
{
"version": 2,
"generatedAt": "2026-05-05T08:55:53.722Z",
"pets": [
{
"id": "snoopy",
"displayName": "Snoopy",
"description": "A tiny black-and-white beagle with a red collar for calm coding sessions.",
"preview": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip"
},
{
"id": "clippit",
"displayName": "Clippy",
"description": "A classic paperclip assistant rebuilt from Microsoft Agent animation frames.",
"preview": "https://openpets.dev/pets/clippit-904b393f/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/clippit-904b393f/clippit.zip"
},
{
"id": "tux",
"displayName": "Tux",
"description": "A tiny pixel-adjacent Linux mascot penguin for calm coding sessions.",
"preview": "https://openpets.dev/pets/tux-de2f300f/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/tux-de2f300f/tux.zip"
},
{
"id": "wall-e",
"displayName": "Wall-E",
"description": "A tiny weathered trash-compactor robot companion with binocular eyes, treads, and a gentle waving arm.",
"preview": "https://openpets.dev/pets/wall-e-779d5202/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/wall-e-779d5202/wall-e.zip"
},
{
"id": "dobby",
"displayName": "Dobby",
"description": "An earnest, genuinely helpful tiny house-elf Codex pet with huge ears, wide kind eyes, and a simple worn tunic.",
"preview": "https://openpets.dev/pets/dobby-3f6746e0/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/dobby-3f6746e0/dobby.zip"
}
]
}

View file

@ -0,0 +1,55 @@
appId: dev.openpets.app
productName: OpenPets
copyright: Copyright © 2026 OpenPets
directories:
output: dist-electron
artifactName: OpenPets-${version}-${os}-${arch}.${ext}
files:
- dist/**
- preload.cjs
- pet-preload.cjs
- assets/**
- package.json
asar: true
asarUnpack:
- node_modules/**
npmRebuild: false
publish: null
mac:
icon: assets/app-icon.icns
category: public.app-category.utilities
target:
- dmg
- zip
identity: null
hardenedRuntime: false
gatekeeperAssess: false
win:
icon: assets/app-icon.ico
target:
- nsis
- portable
verifyUpdateCodeSignature: false
nsis:
artifactName: OpenPets-${version}-win-${arch}-setup.${ext}
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
portable:
artifactName: OpenPets-${version}-win-${arch}-portable.${ext}
linux:
target:
- AppImage
- deb
- tar.gz
category: Utility
maintainer: OpenPets

39
apps/desktop/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@open-pets/desktop",
"version": "2.0.0",
"private": true,
"description": "OpenPets tray-first desktop companion app.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/alvinunreal/openpets.git",
"directory": "apps/desktop"
},
"author": "OpenPets",
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "pnpm build && electron .",
"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder",
"package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output",
"test": "node --check preload.cjs && node --check pet-preload.cjs && node dist/check-catalog-fixture.js && node dist/check-codex-pets.js && node dist/check-zip-safety.js && node dist/check-local-ipc-protocol.js && node dist/check-lease-manager.js && node dist/check-onboarding-state.js && node dist/check-update-checker.js && node dist/check-claude-memory.js && node dist/check-opencode-desktop-setup.js && node dist/check-packaging-contract.js",
"check": "pnpm typecheck && pnpm build && pnpm test",
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@types/yauzl": "2.10.3",
"electron": "^42.0.0",
"electron-builder": "^26.8.1",
"typescript": "^6.0.3"
},
"dependencies": {
"@open-pets/agent-events": "workspace:*",
"@open-pets/claude": "workspace:*",
"@open-pets/cli": "workspace:*",
"@open-pets/mcp": "workspace:*",
"@open-pets/opencode": "workspace:*",
"yauzl": "3.3.0"
}
}

View file

@ -0,0 +1,88 @@
const { ipcRenderer } = require("electron");
const allowedMotionStates = new Set(["idle", "run-left", "run-right"]);
const allowedReactionStates = new Set(["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]);
let lastInteractiveHit = null;
let dragging = false;
ipcRenderer.on("openpets:pet-motion", (_event, state) => {
if (!allowedMotionStates.has(state)) {
return;
}
const apply = () => {
document.documentElement.dataset.motionState = state;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", apply, { once: true });
} else {
apply();
}
});
ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
if (!allowedReactionStates.has(state)) {
return;
}
const apply = () => {
document.documentElement.dataset.reactionState = state;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", apply, { once: true });
} else {
apply();
}
});
const getInteractiveTarget = (event) => {
const target = document.elementFromPoint(event.clientX, event.clientY);
return target && target.closest(".pet-shell, .bubble");
};
const setInteractiveHit = (interactive) => {
if (lastInteractiveHit === interactive) return;
lastInteractiveHit = interactive;
ipcRenderer.send("openpets:pet-hit-test", interactive);
};
const updateInteractiveHit = (event) => {
setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging);
};
const installMouseInterop = () => {
document.addEventListener("mousemove", (event) => {
updateInteractiveHit(event);
if (dragging) ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY });
}, { passive: true });
document.addEventListener("mousedown", (event) => {
const target = getInteractiveTarget(event);
setInteractiveHit(Boolean(target));
if (event.button !== 0 || !target?.closest(".pet-shell")) return;
event.preventDefault();
dragging = true;
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY });
});
document.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
ipcRenderer.send("openpets:pet-drag-end");
});
document.addEventListener("mouseleave", () => {
if (!dragging) setInteractiveHit(false);
}, { passive: true });
setInteractiveHit(false);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installMouseInterop, { once: true });
} else {
installMouseInterop();
}

1262
apps/desktop/preload.cjs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
const { rmSync } = require("node:fs");
const { basename, dirname, resolve } = require("node:path");
const scriptsDir = __dirname;
const desktopDir = resolve(scriptsDir, "..");
const target = resolve(desktopDir, "dist-electron");
if (basename(target) !== "dist-electron" || dirname(target) !== desktopDir) {
throw new Error(`Refusing to clean unexpected package output path: ${target}`);
}
rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });

View file

@ -0,0 +1,218 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const desktopDir = resolve(scriptsDir, "..");
const repoRoot = resolve(desktopDir, "../..");
const outputDir = join(desktopDir, "dist-electron");
const repository = "alvinunreal/openpets";
const allowedArgs = new Set([
"--dry-run",
"--yes",
"--include-optional",
"--include-mac-zip",
"--include-win-portable",
"--include-linux-deb",
"--include-linux-targz",
"--include-experimental-arm",
"--skip-checks",
"--help",
]);
const rawArgs = process.argv.slice(2).filter((arg) => arg !== "--");
const unknownArgs = rawArgs.filter((arg) => !allowedArgs.has(arg));
if (unknownArgs.length > 0) throw new Error(`Unknown release option(s): ${unknownArgs.join(", ")}`);
const args = new Set(rawArgs);
const dryRun = args.has("--dry-run");
const yes = args.has("--yes");
const includeOptional = args.has("--include-optional");
const includeMacZip = includeOptional || args.has("--include-mac-zip");
const includeWinPortable = includeOptional || args.has("--include-win-portable");
const includeLinuxDeb = includeOptional || args.has("--include-linux-deb");
const includeLinuxTarGz = includeOptional || args.has("--include-linux-targz");
const includeExperimentalArm = args.has("--include-experimental-arm");
const skipChecks = args.has("--skip-checks");
if (skipChecks && yes) throw new Error("Refusing to create a release with --skip-checks. Run checks before using --yes.");
if (args.has("--help")) {
printHelp();
process.exit(0);
}
const desktopPackageJson = readJson(join(desktopDir, "package.json"));
const version = desktopPackageJson.version;
const tag = `v${version}`;
main();
function main() {
preflight();
if (!skipChecks) {
run("pnpm", ["build"], { cwd: repoRoot });
run("pnpm", ["--filter", "@open-pets/desktop", "check"], { cwd: repoRoot });
}
run("node", ["scripts/clean-package-output.cjs"], { cwd: desktopDir });
mkdirSync(outputDir, { recursive: true });
for (const build of createBuildPlan()) {
run("pnpm", ["exec", "electron-builder", ...build.args, "--publish", "never"], { cwd: desktopDir });
}
const postBuildStatus = getGitStatusIgnoringPackageOutput();
if (postBuildStatus) throw new Error(`Build/checks changed tracked or source files. Commit or revert them before releasing.\n${postBuildStatus}`);
const artifacts = collectArtifacts(outputDir);
if (artifacts.length === 0) throw new Error("No release artifacts were produced.");
const checksumsPath = writeChecksums(artifacts);
const uploadArtifacts = [...artifacts, checksumsPath];
console.log("\nRelease artifacts:");
for (const artifact of uploadArtifacts) console.log(`- ${relative(repoRoot, artifact)}`);
if (!yes && !dryRun) {
throw new Error("Re-run with --yes to create the draft GitHub release after reviewing the artifact list.");
}
if (dryRun) {
console.log(`\nDry run complete. Would create draft release ${tag} in ${repository}.`);
return;
}
const target = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim();
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--draft", "--title", `OpenPets ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "upload", tag, "--repo", repository, ...uploadArtifacts], { cwd: repoRoot });
console.log(`\nDraft release created: https://github.com/${repository}/releases/tag/${tag}`);
console.log("Draft releases are not visible to the app update checker until you publish them.");
}
function preflight() {
if (process.platform !== "darwin") throw new Error("This local release script is intended to run from macOS.");
if (!isStableSemver(version) || version === "0.0.0") {
throw new Error(`Desktop package version must be a stable non-zero semver version. Current: ${version}`);
}
requireCommand("pnpm", ["--version"]);
requireCommand("gh", ["--version"]);
run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: repoRoot });
const remoteUrl = commandOutput("git", ["remote", "get-url", "origin"], { cwd: repoRoot }).trim();
if (!remoteUrl.includes(repository)) {
throw new Error(`Expected origin remote to point at ${repository}. Current origin: ${remoteUrl}`);
}
const status = commandOutput("git", ["status", "--porcelain"], { cwd: repoRoot }).trim();
if (status) throw new Error(`Git working tree must be clean before release.\n${status}`);
run("git", ["rev-parse", "--verify", "HEAD"], { cwd: repoRoot });
const upstream = commandOutput("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: repoRoot }).trim();
if (!upstream) throw new Error("Release branch must have an upstream remote branch.");
run("git", ["fetch", "--tags", "origin"], { cwd: repoRoot });
const localHead = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim();
const remoteHead = commandOutput("git", ["rev-parse", upstream], { cwd: repoRoot }).trim();
if (localHead !== remoteHead) throw new Error(`HEAD must be pushed to ${upstream} before release.`);
if (commandSucceeds("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: repoRoot })) {
throw new Error(`Git tag already exists locally: ${tag}`);
}
if (commandSucceeds("git", ["ls-remote", "--exit-code", "--tags", "origin", tag], { cwd: repoRoot })) {
throw new Error(`Git tag already exists on origin: ${tag}`);
}
if (commandSucceeds("gh", ["release", "view", tag, "--repo", repository], { cwd: repoRoot })) {
throw new Error(`GitHub release already exists: ${tag}`);
}
}
function createBuildPlan() {
const plan = [
{ name: "mac dmg x64+arm64", args: ["--mac", "dmg", "--x64", "--arm64"] },
{ name: "windows nsis x64", args: ["--win", "nsis", "--x64"] },
{ name: "linux AppImage x64", args: ["--linux", "AppImage", "--x64"] },
];
if (includeMacZip) plan.push({ name: "mac zip x64+arm64", args: ["--mac", "zip", "--x64", "--arm64"] });
if (includeWinPortable) plan.push({ name: "windows portable x64", args: ["--win", "portable", "--x64"] });
if (includeLinuxDeb) plan.push({ name: "linux deb x64", args: ["--linux", "deb", "--x64"] });
if (includeLinuxTarGz) plan.push({ name: "linux tar.gz x64", args: ["--linux", "tar.gz", "--x64"] });
if (includeExperimentalArm) {
plan.push({ name: "windows nsis arm64", args: ["--win", "nsis", "--arm64"] });
plan.push({ name: "linux AppImage arm64", args: ["--linux", "AppImage", "--arm64"] });
}
console.log("Build plan:");
for (const build of plan) console.log(`- ${build.name}`);
return plan;
}
function collectArtifacts(dir) {
const allowedNames = new Set(["SHA256SUMS"]);
const allowedExtensions = new Set([".dmg", ".zip", ".exe", ".AppImage", ".deb"]);
const artifacts = [];
for (const entry of readdirSync(dir)) {
const filePath = join(dir, entry);
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const name = basename(filePath);
if (allowedNames.has(name) || allowedExtensions.has(extname(name)) || name.endsWith(".tar.gz")) artifacts.push(filePath);
}
return artifacts.filter((path) => basename(path) !== "SHA256SUMS").sort();
}
function writeChecksums(artifacts) {
const lines = artifacts.map((artifact) => `${sha256(artifact)} ${basename(artifact)}`);
const checksumsPath = join(outputDir, "SHA256SUMS");
writeFileSync(checksumsPath, `${lines.join("\n")}\n`);
return checksumsPath;
}
function sha256(filePath) {
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function getGitStatusIgnoringPackageOutput() {
return commandOutput("git", ["status", "--porcelain"], { cwd: repoRoot })
.split("\n")
.filter((line) => line.trim() && !line.includes("apps/desktop/dist-electron/"))
.join("\n");
}
function isStableSemver(value) {
return /^\d+\.\d+\.\d+$/.test(value);
}
function requireCommand(command, args) {
if (!commandSucceeds(command, args, { cwd: repoRoot })) throw new Error(`Required command is unavailable: ${command}`);
}
function commandSucceeds(command, args, options) {
return spawnSync(command, args, { cwd: options.cwd, stdio: "ignore" }).status === 0;
}
function commandOutput(command, args, options) {
const result = spawnSync(command, args, { cwd: options.cwd, encoding: "utf8" });
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout}`);
return result.stdout;
}
function run(command, args, options) {
console.log(`\n$ ${command} ${args.join(" ")}`);
const result = spawnSync(command, args, { cwd: options.cwd, stdio: "inherit" });
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`);
}
function defaultReleaseNotes() {
return [
`Local draft release for OpenPets ${tag}.`,
"",
"Before publishing:",
"- Smoke test macOS, Windows, and Linux artifacts.",
"- Expect Gatekeeper/SmartScreen warnings until signing/notarization is configured.",
].join("\n");
}
function printHelp() {
console.log(`Usage: pnpm release:desktop -- --yes\n\nBuilds local desktop release artifacts, creates a draft GitHub release, and uploads artifacts.\n\nDefault targets:\n - macOS dmg x64+arm64\n - Windows nsis x64\n - Linux AppImage x64\n\nOptions:\n --yes create the draft GitHub release after building\n --dry-run run checks/builds and print what would be released\n --skip-checks skip pnpm build and desktop check\n --include-optional include all optional x64 targets below\n --include-mac-zip also build macOS zip x64+arm64\n --include-win-portable also build Windows portable x64\n --include-linux-deb also build Linux deb x64\n --include-linux-targz also build Linux tar.gz x64\n --include-experimental-arm also build Windows/Linux ARM64 artifacts\n`);
}

View file

@ -0,0 +1,139 @@
import { BrowserWindow } from "electron";
import { getAppStateSnapshot } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
const agentPetWindows = new Map<string, BrowserWindow>();
const transientDisplays = new Map<string, PetTransientDisplay>();
const transientTimers = new Map<string, NodeJS.Timeout>();
const transientAnimationTimers = new Map<string, NodeJS.Timeout>();
const dismissedAgentPets = new Set<string>();
export function showAgentPet(petId: string): boolean {
if (dismissedAgentPets.has(petId)) return false;
const window = getOrCreateAgentPetWindow(petId);
if (window.isMinimized()) window.restore();
window.showInactive();
return true;
}
export function closeAgentPetIfOpen(petId: string): void {
const window = agentPetWindows.get(petId);
if (!window || window.isDestroyed()) return;
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
window.destroy();
}
export function dismissAgentPetForActiveLease(petId: string): void {
dismissedAgentPets.add(petId);
closeAgentPetIfOpen(petId);
}
export function clearAgentPetDismissal(petId: string): void {
dismissedAgentPets.delete(petId);
}
export function clearAgentPetLeaseState(petId: string): void {
dismissedAgentPets.delete(petId);
closeAgentPetIfOpen(petId);
clearAgentDisplay(petId);
}
export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
setAgentDisplay(petId, { reaction });
const shown = showAgentPet(petId);
return shown ? { shown } : { shown, reason: "dismissed" };
}
export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
setAgentDisplay(petId, { message, reaction });
const shown = showAgentPet(petId);
return shown ? { shown } : { shown, reason: "dismissed" };
}
export function closeAllAgentPets(): void {
for (const petId of [...agentPetWindows.keys()]) {
closeAgentPetIfOpen(petId);
}
}
export function refreshAgentPetContent(): void {
for (const [petId, window] of agentPetWindows.entries()) {
if (!window.isDestroyed()) {
void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null);
}
}
}
function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
const existing = agentPetWindows.get(petId);
if (existing && !existing.isDestroyed()) return existing;
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === petId);
if (!pet) throw new Error(`Installed pet is unavailable: ${petId}`);
const offset = agentPetWindows.size + 1;
const initial = getDefaultPetInitialPosition(defaultPetWindowSize);
const window = createAgentPetWindow({
petId,
displayName: pet.displayName,
position: { x: initial.x - offset * 36, y: initial.y - offset * 24 },
display: transientDisplays.get(petId) ?? null,
onCloseRequested: () => dismissAgentPetForActiveLease(petId),
});
window.on("closed", () => {
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
});
agentPetWindows.set(petId, window);
return window;
}
function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, display);
transientDisplays.set(petId, preparedDisplay);
const existingTimer = transientTimers.get(petId);
if (existingTimer) clearTimeout(existingTimer);
const existingAnimationTimer = transientAnimationTimers.get(petId);
if (existingAnimationTimer) clearTimeout(existingAnimationTimer);
const animationMs = getTransientReactionAnimationMs(preparedDisplay);
if (animationMs !== null && animationMs < transientDisplayMs) {
const animationTimer = setTimeout(() => {
const current = transientDisplays.get(petId);
if (!current) return;
const updated = clearTransientReaction(current);
transientDisplays.set(petId, updated);
transientAnimationTimers.delete(petId);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) setPetReactionState(window, "idle");
}, animationMs);
transientAnimationTimers.set(petId, animationTimer);
}
const timer = setTimeout(() => {
transientDisplays.delete(petId);
transientTimers.delete(petId);
const animationTimer = transientAnimationTimers.get(petId);
if (animationTimer) clearTimeout(animationTimer);
transientAnimationTimers.delete(petId);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null);
}, transientDisplayMs);
transientTimers.set(petId, timer);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay);
}
function clearAgentDisplay(petId: string): void {
const timer = transientTimers.get(petId);
if (timer) clearTimeout(timer);
const animationTimer = transientAnimationTimers.get(petId);
if (animationTimer) clearTimeout(animationTimer);
transientTimers.delete(petId);
transientAnimationTimers.delete(petId);
transientDisplays.delete(petId);
}

View file

@ -0,0 +1,607 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { app } from "electron";
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, createOpenPetsHookSettingsPreview, doctorClaudeHooks, installClaudeHooks, mapAsarPathToUnpacked, uninstallClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type OpenPetsCommandMode, type ParsedClaudeMcpEntry } from "@open-pets/claude";
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
import { getAppStateSnapshot, type InstalledPetState } from "./app-state.js";
import { doctorClaudeOpenPetsMemory, installClaudeOpenPetsMemory, uninstallClaudeOpenPetsMemory, type ClaudeOpenPetsMemoryStatus } from "./claude-memory.js";
export type AgentSetupAction = "configure" | "replace" | "remove" | "install-memory" | "doctor-hooks" | "install-hooks" | "uninstall-hooks" | "opencode-install" | "opencode-remove";
export type JournalAction = "configure" | "update" | "replace" | "remove";
export interface AgentSetupPetOption {
readonly id: string;
readonly displayName: string;
readonly default: boolean;
}
export interface ClaudeCodeStatus {
readonly state: "detected" | "not_detected" | "configured" | "needs_setup" | "error";
readonly label: string;
readonly details: string;
readonly claudeCommand?: string;
readonly version?: string;
readonly mcpListWorks: boolean;
readonly openPetsEntry: ParsedClaudeMcpEntry;
readonly canConfigure: boolean;
readonly canReplace: boolean;
readonly canRemove: boolean;
}
export interface AgentSetupSnapshot {
readonly selectedPetId?: string;
readonly commandMode: OpenPetsCommandMode;
readonly localDevAvailable: boolean;
readonly petOptions: readonly AgentSetupPetOption[];
readonly preview: ClaudeMcpPreview;
readonly status: ClaudeCodeStatus;
readonly hookStatus: ClaudeHookDoctorResult;
readonly memoryStatus: ClaudeOpenPetsMemoryStatus;
readonly opencodeStatus: OpenCodeSetupStatus;
readonly opencodePreview: OpenCodeSetupPreview;
readonly busy: boolean;
readonly lastAction?: AgentSetupActionResult;
}
export interface OpenCodeSetupStatus {
readonly state: "configured" | "needs_setup" | "not_detected" | "error";
readonly label: string;
readonly details: string;
readonly configDir: string;
readonly canInstall: boolean;
readonly canRemove: boolean;
}
export interface OpenCodeSetupPreview {
readonly global: true;
readonly configDir: string;
readonly configPath: string;
readonly cleanupConfigPaths: readonly string[];
readonly mcpCommand: readonly string[];
readonly plugin: readonly unknown[] | string;
readonly instructionPath: string;
readonly configPreview: Record<string, unknown>;
}
export interface AgentSetupActionResult {
readonly ok: boolean;
readonly action: AgentSetupAction;
readonly message: string;
readonly changed: boolean;
}
export interface AgentSetupJournalEntry {
readonly timestamp: string;
readonly action: JournalAction;
readonly selectedPetId?: string;
readonly command: readonly string[];
readonly previousStatus: string;
readonly success: boolean;
readonly message: string;
}
interface CommandResult {
readonly ok: boolean;
readonly timedOut: boolean;
readonly exitCode: number | null;
readonly stdout: string;
readonly stderr: string;
readonly error?: string;
}
const commandTimeoutMs = 6_000;
const maxOutputBytes = 16_384;
const require = createRequire(import.meta.url);
let operationRunning = false;
let lastAction: AgentSetupActionResult | undefined;
export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandModeInput?: unknown): Promise<AgentSetupSnapshot> {
const petId = validateSelectedPetId(selectedPetId);
const commandMode = validateCommandMode(commandModeInput);
const preview = safeBuildClaudeMcpPreview(petId, commandMode);
const status = preview.error ? createBundledResourceErrorStatus(preview.error) : await detectClaudeCodeStatus(petId, commandMode);
const rawHookStatus = preview.error ? createHookErrorStatus(preview.error) : safeDoctorClaudeHooks(commandMode, petId);
const hookStatus = { ...rawHookStatus, settingsPath: formatUserPath(rawHookStatus.settingsPath) ?? rawHookStatus.settingsPath, backupPath: formatUserPath(rawHookStatus.backupPath) };
const rawMemoryStatus = doctorClaudeOpenPetsMemory(app.getPath("home"));
const memoryStatus = { ...rawMemoryStatus, claudeMdPath: formatUserPath(rawMemoryStatus.claudeMdPath) ?? rawMemoryStatus.claudeMdPath, openPetsMemoryPath: formatUserPath(rawMemoryStatus.openPetsMemoryPath) ?? rawMemoryStatus.openPetsMemoryPath };
const opencode = await getOpenCodeSetup(commandMode, petId);
return {
selectedPetId: petId,
commandMode,
localDevAvailable: !app.isPackaged,
petOptions: getPetOptions(),
preview: preview.preview,
status,
hookStatus,
memoryStatus,
opencodeStatus: opencode.status,
opencodePreview: opencode.preview,
busy: operationRunning,
lastAction,
};
}
export async function runAgentSetupAction(action: AgentSetupAction, selectedPetId?: unknown, commandModeInput?: unknown): Promise<AgentSetupSnapshot> {
if (operationRunning) throw new Error("Another Claude setup operation is already running.");
const petId = validateSelectedPetId(selectedPetId);
const commandMode = validateCommandMode(commandModeInput);
operationRunning = true;
try {
lastAction = await runAction(action, petId, commandMode);
operationRunning = false;
return getAgentSetupSnapshot(petId, commandMode);
} finally {
operationRunning = false;
}
}
export function sanitizeAgentSetupOutput(value: string): string {
const home = app.isReady() ? app.getPath("home") : "";
return value
.replaceAll(home, "~")
.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "<path>")
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "<redacted-private-key>")
.replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
.replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=<redacted>")
.slice(0, 500);
}
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
try {
return { preview: buildClaudeMcpPreview(selectedPetId, commandMode) };
} catch (error) {
const message = error instanceof Error ? error.message : "Packaged OpenPets command resources are unavailable.";
return { preview: createErrorPreview(commandMode, message), error: message };
}
}
function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
try {
return doctorClaudeHooks(undefined, commandMode, selectedPetId);
} catch (error) {
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable.");
}
}
function createErrorPreview(commandMode: OpenPetsCommandMode, message: string): ClaudeMcpPreview {
return {
commandMode,
add: { command: "claude", args: [] },
remove: { command: "claude", args: ["mcp", "remove", "--scope", "user", "openpets"] },
mcpJson: { mcpServers: { openpets: { type: "stdio", command: "node", args: [] } } },
displayCommand: message,
};
}
function createBundledResourceErrorStatus(message: string): ClaudeCodeStatus {
return createStatus("error", "Packaged commands unavailable", message, undefined, { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: message }, { present: false, source: "none", verified: false, matchesExpected: false });
}
function createHookErrorStatus(message: string): ClaudeHookDoctorResult {
return { status: "error", settingsPath: "~/.claude/settings.json", exists: false, valid: false, message, preview: {}, asyncSupported: false };
}
async function runAction(action: AgentSetupAction, selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
if (action === "opencode-install") return installOpenCodeGlobal(selectedPetId, commandMode);
if (action === "opencode-remove") return removeOpenCodeGlobal();
if (action === "doctor-hooks") {
const doctor = safeDoctorClaudeHooks(commandMode, selectedPetId);
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("doctor-hooks", selectedPetId), previousStatus: doctor.status, success: doctor.status !== "error", message: doctor.message });
return { ok: doctor.status !== "error", action, message: doctor.message, changed: false };
}
if (action === "uninstall-hooks") {
let result;
try {
result = uninstallClaudeHooks(undefined, commandMode);
} catch (error) {
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook uninstall failed.", changed: false };
}
const message = result.changed ? `Uninstalled OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
writeActionJournal({ action: "remove", selectedPetId, command: ["open-pets-claude", "uninstall-hooks"], previousStatus: result.status, success: result.status !== "error", message });
return { ok: result.status !== "error", action, message, changed: result.changed };
}
if (action === "install-memory") {
const result = safeInstallClaudeMemory();
return { ok: result.ok, action, message: result.ok ? result.message : `Claude instructions were not updated: ${result.message}`, changed: result.ok && result.message.startsWith("Added") };
}
if (action === "remove") {
return runRemove(createErrorPreview(commandMode, ""), selectedPetId, "Unknown", action);
}
if (commandMode === "bundled") {
const node = await runCommand({ command: "node", args: ["--version"] });
if (!node.ok) return { ok: false, action, message: `Packaged OpenPets Claude commands require node on Claude's PATH: ${summarizeCommandResult(node)}`, changed: false };
}
const previewResult = safeBuildClaudeMcpPreview(selectedPetId, commandMode);
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
if (action === "install-hooks") {
let result;
try {
result = installClaudeHooks(undefined, commandMode, selectedPetId);
} catch (error) {
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook install failed.", changed: false };
}
const message = result.changed ? `Installed OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("install-hooks", selectedPetId), previousStatus: result.status, success: result.status !== "error", message });
return { ok: result.status !== "error", action, message, changed: result.changed };
}
const detection = await detectClaudeCodeStatus(selectedPetId, commandMode);
const previousStatus = detection.label;
const preview = previewResult.preview;
if (detection.state === "not_detected") {
const result = { ok: false, action, message: "Claude Code was not found. Install Claude Code or use Copy command to configure manually.", changed: false };
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: false, message: result.message });
return result;
}
if (action === "configure") {
if (detection.openPetsEntry.present && detection.openPetsEntry.verified && detection.openPetsEntry.matchesExpected) {
const memoryResult = safeInstallClaudeMemory();
const message = `OpenPets MCP is already configured for Claude Code.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`;
return { ok: true, action, message, changed: memoryResult.ok && memoryResult.message.startsWith("Added") };
}
if (detection.openPetsEntry.present) {
return { ok: false, action, message: "Claude already has an openpets MCP entry. OpenPets will keep it as installed; use Replace only if you want to recreate it with the recommended command.", changed: false };
}
return runAdd(preview, selectedPetId, previousStatus, action);
}
if (!detection.openPetsEntry.present) {
return runAdd(preview, selectedPetId, previousStatus, action);
}
const removed = await runRemove(preview, selectedPetId, previousStatus, action);
if (!removed.ok) return removed;
const added = await runAdd(preview, selectedPetId, previousStatus, action);
if (!added.ok) {
return {
ok: false,
action,
message: `${added.message} The previous openpets entry was removed; use this command to restore the intended entry: ${preview.displayCommand}`,
changed: true,
};
}
return { ok: true, action, message: `Replaced Claude Code OpenPets MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`, changed: true };
}
async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const petId = selectedPetId || undefined;
const cliVersion = getCliPackageVersion();
const pluginVersion = getOpenCodePackageVersion();
const cliEntryPath = commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode);
const prepared = safePrepareOpenCode(configDir, petId, cliVersion, pluginVersion, commandMode, cliEntryPath);
const detected = await runCommand({ command: process.platform === "win32" ? "opencode.cmd" : "opencode", args: ["--version"] });
const globalState = doctorOpenCodeGlobalSetup(configDir);
const configured = globalState.status === "installed";
return {
status: {
state: globalState.status === "error" || globalState.status === "custom" || globalState.status === "conflict" ? "error" : configured ? "configured" : detected.ok ? "needs_setup" : "not_detected",
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detected.ok ? "Ready" : "Not detected",
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detected.ok ? "OpenCode was detected. Desktop setup writes global OpenCode config." : "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it.",
configDir: formatUserPath(configDir) ?? configDir,
canInstall: prepared.ok,
canRemove: configured,
},
preview: {
global: true,
configDir: formatUserPath(configDir) ?? configDir,
configPath: prepared.ok ? (formatUserPath(prepared.configPath) ?? prepared.configPath) : "",
cleanupConfigPaths: prepared.ok ? prepared.cleanupConfigPaths.map((path) => formatUserPath(path) ?? path) : [],
mcpCommand: prepared.ok ? prepared.command : [],
plugin: prepared.ok ? prepared.plugin : (petId ? [`@open-pets/opencode@${pluginVersion}`, { pet: petId }] : `@open-pets/opencode@${pluginVersion}`),
instructionPath: prepared.ok ? (formatUserPath(prepared.instructionPath) ?? prepared.instructionPath) : "",
configPreview: prepared.ok ? prepared.configPreview : {},
},
};
}
function safePrepareOpenCode(configDir: string, selectedPetId: string | undefined, cliVersion: string, pluginVersion: string, commandMode: OpenPetsCommandMode, cliEntryPath: string | undefined): { readonly ok: true; readonly command: readonly string[]; readonly configPath: string; readonly cleanupConfigPaths: readonly string[]; readonly instructionPath: string; readonly plugin: readonly unknown[] | string; readonly configPreview: Record<string, unknown> } | { readonly ok: false; readonly message: string } {
try {
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion, pluginVersion, commandMode, cliEntryPath });
const parsed = parseOpenCodeConfig(prepared.configWrite.content);
if (!parsed.ok) return { ok: false, message: parsed.message };
const config = parsed.value as { mcp?: { openpets?: { command?: readonly string[] } }; plugin?: readonly unknown[] };
const plugin = Array.isArray(config.plugin) ? config.plugin[config.plugin.length - 1] : undefined;
return { ok: true, command: config.mcp?.openpets?.command ?? [], configPath: prepared.configPath, cleanupConfigPaths: prepared.cleanupConfigWrites.map((write) => write.targetPath), instructionPath: prepared.instructionPath, plugin: plugin === undefined ? [] : (plugin as readonly unknown[] | string), configPreview: parsed.value };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "OpenCode setup preview failed." };
}
}
async function installOpenCodeGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
if (commandMode === "bundled") {
const node = await runCommand({ command: "node", args: ["--version"] });
if (!node.ok) return { ok: false, action: "opencode-install", message: `Packaged OpenPets OpenCode setup requires node on OpenCode's PATH: ${summarizeCommandResult(node)}`, changed: false };
}
try {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion: getCliPackageVersion(), pluginVersion: getOpenCodePackageVersion(), commandMode, cliEntryPath: commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode) });
writePreparedOpenCodeGlobalSetup(prepared);
return { ok: true, action: "opencode-install", message: `Installed global OpenCode OpenPets setup. Config: ${formatUserPath(prepared.configPath) ?? prepared.configPath}. Instructions: ${formatUserPath(prepared.instructionPath) ?? prepared.instructionPath}.`, changed: true };
} catch (error) {
return { ok: false, action: "opencode-install", message: error instanceof Error ? error.message : "OpenCode setup failed.", changed: false };
}
}
async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
try {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const prepared = prepareOpenCodeGlobalRemove(configDir);
writePreparedOpenCodeGlobalRemove(prepared);
return { ok: true, action: "opencode-remove", message: prepared.configWrites.length > 0 ? "Removed global OpenCode OpenPets setup." : "Global OpenCode OpenPets setup was already absent.", changed: prepared.configWrites.length > 0 };
} catch (error) {
return { ok: false, action: "opencode-remove", message: error instanceof Error ? error.message : "OpenCode removal failed.", changed: false };
}
}
function getDesktopCliEntryPath(commandMode: OpenPetsCommandMode): string {
const path = require.resolve("@open-pets/cli");
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
}
function getCliPackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/cli");
}
function getOpenCodePackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/opencode");
}
function getWorkspacePackageVersion(packageName: string): string {
try {
const entryPath = require.resolve(packageName);
const packageJsonPath = join(dirname(dirname(entryPath)), "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { readonly version?: unknown };
return typeof packageJson.version === "string" && packageJson.version ? packageJson.version : "0.0.0";
} catch {
return "0.0.0";
}
}
function summarizeMemoryMessages(...messages: readonly string[]): string {
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:OpenPets )?instructions[^.]*\./g) ?? []);
return memoryMessages.length > 0 ? ` ${memoryMessages.join(" ")}` : "";
}
function createHookJournalCommand(command: "doctor-hooks" | "install-hooks", selectedPetId: string | undefined): readonly string[] {
return selectedPetId ? ["open-pets-claude", command, "--pet", selectedPetId] : ["open-pets-claude", command];
}
async function runAdd(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
const result = await runClaudeCommand(preview.add);
const memoryResult = result.ok ? safeInstallClaudeMemory() : { ok: false as const, message: "" };
const message = result.ok
? `Configured Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP add failed: ${summarizeCommandResult(result)}`;
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: result.ok, message });
return { ok: result.ok, action, message, changed: result.ok };
}
async function runRemove(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
const result = await runClaudeCommand(preview.remove);
const memoryResult = result.ok ? safeUninstallClaudeMemory() : { ok: false as const, message: "" };
const message = result.ok
? `Removed Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP remove failed: ${summarizeCommandResult(result)}`;
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.remove.command, ...preview.remove.args], previousStatus, success: result.ok, message });
return { ok: result.ok, action, message, changed: result.ok };
}
function safeInstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
try {
const result = installClaudeOpenPetsMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Added Claude OpenPets instructions." : "Claude OpenPets instructions already present." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
}
function safeUninstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
try {
const result = uninstallClaudeOpenPetsMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Removed Claude OpenPets instructions." : "Claude OpenPets instructions were already absent." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
}
async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<ClaudeCodeStatus> {
if (commandMode === "bundled") {
const node = await runCommand({ command: "node", args: ["--version"] });
if (!node.ok) return createStatus("error", "Node required", `Packaged OpenPets Claude commands require node on Claude's PATH: ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
}
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
if (!version.ok) {
return createStatus("not_detected", "Not detected", `Claude Code was not found or did not run: ${summarizeCommandResult(version)}`, undefined, version, { present: false, source: "none", verified: false, matchesExpected: false });
}
const list = await runClaudeCommandWithTimeoutRetry({ command: "claude", args: ["mcp", "list"] });
if (!list.ok) {
return createStatus("error", "Error / needs attention", `Claude Code was detected, but MCP status failed: ${summarizeCommandResult(list)}`, sanitizeAgentSetupOutput(version.stdout || version.stderr), list, { present: false, source: "none", verified: false, matchesExpected: false });
}
const listed = classifyClaudeMcpStatus(list.stdout, undefined, selectedPetId, commandMode);
let entry = listed;
if (listed.present) {
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode);
}
if (!entry.present) return createStatus("needs_setup", "Needs setup", "Claude Code is detected, but OpenPets MCP is not configured.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified && entry.matchesExpected) return createStatus("configured", "Configured", "Claude Code has the expected OpenPets MCP entry.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified) return createStatus("configured", "Installed — custom", "Claude Code has an openpets MCP entry with a custom command. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
return createStatus("configured", "Installed — unverified", "Claude Code lists an openpets MCP entry, but command details were not available. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
}
async function runClaudeCommandWithTimeoutRetry(spec: ClaudeCommandSpec): Promise<CommandResult> {
const first = await runClaudeCommand(spec);
if (!first.timedOut) return first;
await delay(250);
const second = await runClaudeCommand(spec);
return second.ok ? second : first;
}
function createStatus(state: ClaudeCodeStatus["state"], label: string, details: string, version: string | undefined, listResult: CommandResult, entry: ParsedClaudeMcpEntry): ClaudeCodeStatus {
return {
state,
label,
details,
claudeCommand: "claude",
version,
mcpListWorks: listResult.ok,
openPetsEntry: entry,
canConfigure: state === "needs_setup",
canReplace: entry.present && !(entry.verified && entry.matchesExpected),
canRemove: entry.present,
};
}
function validateSelectedPetId(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw new Error("Invalid selected pet id.");
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === value);
if (!pet || pet.broken) throw new Error("Selected pet is not installed or is broken.");
return pet.id;
}
function validateCommandMode(value: unknown): OpenPetsCommandMode {
if (app.isPackaged) return "bundled";
return value === "local" ? "local" : "published";
}
function getPetOptions(): readonly AgentSetupPetOption[] {
const state = getAppStateSnapshot();
return state.pets.installed.filter(isUsablePet).map((pet) => ({ id: pet.id, displayName: pet.displayName, default: pet.id === state.preferences.defaultPetId }));
}
function isUsablePet(pet: InstalledPetState): boolean {
return pet.installed && !pet.broken && !pet.builtIn;
}
async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
for (const command of getClaudeCommandCandidates(spec.command)) {
const result = await runCommand({ command, args: spec.args });
if (result.ok || !isCommandNotFound(result)) return result;
}
return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: "Claude command was not found." };
}
function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
return new Promise((resolve) => {
const command = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
const args = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
const child = spawn(command, args, { cwd: app.getPath("home"), env: createCommandEnv(), windowsHide: true, shell: false });
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill();
resolve({ ok: false, timedOut: true, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: "Command timed out." });
}, commandTimeoutMs);
child.stdout?.on("data", (chunk: Buffer) => { stdout = appendBounded(stdout, chunk.toString("utf8")); });
child.stderr?.on("data", (chunk: Buffer) => { stderr = appendBounded(stderr, chunk.toString("utf8")); });
child.on("error", (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, timedOut: false, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: error.message });
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: code === 0, timedOut: false, exitCode: code, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: undefined });
});
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getClaudeCommandCandidates(command: string): readonly string[] {
if (command !== "claude") return [command];
if (process.platform === "win32") return ["claude", "claude.cmd"];
return ["claude"];
}
function createCommandEnv(): NodeJS.ProcessEnv {
const extraPaths = process.platform === "win32" ? [] : ["/opt/homebrew/bin", "/usr/local/bin"];
const existingPath = process.env.PATH ?? "";
return { ...process.env, PATH: [existingPath, ...extraPaths].filter(Boolean).join(process.platform === "win32" ? ";" : ":") };
}
function isCommandNotFound(result: CommandResult): boolean {
return Boolean(result.error && /ENOENT|not found/i.test(result.error));
}
function summarizeCommandResult(result: CommandResult): string {
if (result.timedOut) return "command timed out.";
const output = sanitizeAgentSetupOutput(result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`);
return output || "command failed.";
}
function formatUserPath(path: string | undefined): string | undefined {
if (!path) return undefined;
return path.replace(app.getPath("home"), "~");
}
function appendBounded(existing: string, next: string): string {
const combined = existing + next;
return combined.length > maxOutputBytes ? combined.slice(combined.length - maxOutputBytes) : combined;
}
function writeActionJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
try {
const path = getJournalPath();
const entries = readActionJournal().concat({ ...entry, command: entry.command.map((part) => formatUserPath(part) ?? part), message: sanitizeAgentSetupOutput(entry.message), timestamp: entry.timestamp || new Date().toISOString() }).slice(-20);
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
} catch (error) {
console.error("Failed to write OpenPets agent setup action journal.", error);
}
}
function readActionJournal(): AgentSetupJournalEntry[] {
const path = getJournalPath();
if (!existsSync(path)) return [];
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
return Array.isArray(parsed) ? parsed.filter(isJournalEntry).slice(-20) : [];
} catch {
return [];
}
}
function getJournalPath(): string {
return join(app.getPath("userData"), "agent-setup-actions.json");
}
function isJournalEntry(value: unknown): value is AgentSetupJournalEntry {
return typeof value === "object" && value !== null && typeof (value as { timestamp?: unknown }).timestamp === "string";
}
function journalActionFor(action: AgentSetupAction): JournalAction {
if (action === "replace") return "replace";
if (action === "remove") return "remove";
return "configure";
}
export const agentSetupInternalsForChecks = {
sanitizeAgentSetupOutput,
createOpenPetsHookSettingsPreview,
};

View file

@ -0,0 +1,29 @@
export interface OnboardingPreferenceLike {
readonly onboardingCompleted?: unknown;
}
export const petScaleOptions = [
{ label: "Small", value: 0.44 },
{ label: "Medium", value: 0.56 },
{ label: "Large", value: 0.72 },
] as const;
export type PetScaleValue = typeof petScaleOptions[number]["value"];
export const defaultPetScale: PetScaleValue = 0.56;
export function normalizePetScale(value: unknown): PetScaleValue {
return petScaleOptions.find((option) => option.value === value)?.value ?? defaultPetScale;
}
export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): boolean {
return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false;
}
export function markOnboardingCompleted<T extends { readonly preferences: Record<string, unknown> }>(state: T): T {
return {
...state,
preferences: {
...state.preferences,
onboardingCompleted: true,
},
};
}

View file

@ -0,0 +1,416 @@
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { app } from "electron";
import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
import { builtInPet } from "./built-in-pet.js";
import type { Point } from "./display.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
export interface InstalledPetState {
readonly id: string;
readonly displayName: string;
readonly description?: string;
readonly builtIn: boolean;
readonly protected: boolean;
readonly installed: boolean;
readonly source?: {
readonly kind?: "catalog";
readonly catalogVersion: 2;
readonly zip: string;
readonly preview: string;
} | {
readonly kind: "codex";
readonly path: string;
};
readonly broken?: boolean;
readonly brokenReason?: string;
}
export interface OpenPetsStateV1 {
readonly version: 1;
readonly preferences: {
readonly defaultPetId: string;
readonly openDefaultPetOnLaunch: boolean;
readonly speechBubblesEnabled: boolean;
readonly petScale: number;
readonly onboardingCompleted: boolean;
};
readonly pets: {
readonly installed: readonly InstalledPetState[];
};
readonly defaultPet: {
readonly position?: Point;
};
}
export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue };
const stateFileName = "openpets-state.json";
let statePath: string | null = null;
let currentState: OpenPetsStateV1 | null = null;
export function initializeAppState(): void {
statePath = join(app.getPath("userData"), stateFileName);
const nextState = normalizeState(readStateFile(statePath));
writeStateToDisk(nextState);
currentState = nextState;
console.log(`OpenPets state initialized at ${statePath}.`);
}
export function getAppStateSnapshot(): OpenPetsStateV1 {
return cloneState(getInitializedState());
}
export function updatePreferences(patch: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1 {
const state = getInitializedState();
const preferences = normalizePreferences({ ...state.preferences, ...patch });
const nextState = normalizeState({
...state,
preferences,
});
commitState(nextState);
return getAppStateSnapshot();
}
export function isOnboardingCompleted(): boolean {
return getInitializedState().preferences.onboardingCompleted;
}
export function completeOnboarding(): OpenPetsStateV1 {
const state = getInitializedState();
const nextState = normalizeState(markOnboardingCompleted(state));
commitState(nextState);
return getAppStateSnapshot();
}
export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
const state = getInitializedState();
const targetPet = state.pets.installed.find((pet) => pet.id === defaultPetId);
if (!targetPet) {
throw new Error(`Cannot set unknown pet as default: ${defaultPetId}`);
}
if (targetPet.broken) {
throw new Error(`Cannot set broken pet as default: ${defaultPetId}`);
}
const nextState = normalizeState({
...state,
preferences: {
...state.preferences,
defaultPetId,
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
const state = getInitializedState();
const nextState = normalizeState({
...state,
defaultPet: {
...state.defaultPet,
position: normalizePosition(position),
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 {
return setDefaultPetPosition(position);
}
export function getDefaultPetPosition(): Point | undefined {
return getInitializedState().defaultPet.position;
}
export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): OpenPetsStateV1 {
const state = getInitializedState();
if (state.pets.installed.some((installedPet) => installedPet.id === pet.id)) {
throw new Error(`Pet is already installed: ${pet.id}`);
}
const nextState = normalizeState({
...state,
pets: {
installed: [
...state.pets.installed,
{
...pet,
builtIn: false,
protected: false,
installed: true,
},
],
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function removePetState(petId: string): OpenPetsStateV1 {
if (petId === builtInPet.id) {
throw new Error("Built-in pet cannot be removed.");
}
const state = getInitializedState();
const existing = state.pets.installed.find((pet) => pet.id === petId);
if (!existing) {
throw new Error(`Pet is not installed: ${petId}`);
}
const nextDefaultPetId = state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId;
const nextState = normalizeState({
...state,
preferences: {
...state.preferences,
defaultPetId: nextDefaultPetId,
},
pets: {
installed: state.pets.installed.filter((pet) => pet.id !== petId),
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function markPetBroken(petId: string, brokenReason: string): OpenPetsStateV1 {
const state = getInitializedState();
if (petId === builtInPet.id) {
return getAppStateSnapshot();
}
const nextState = normalizeState({
...state,
preferences: {
...state.preferences,
defaultPetId: state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId,
},
pets: {
installed: state.pets.installed.map((pet) => pet.id === petId ? { ...pet, broken: true, brokenReason } : pet),
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function getStateFilePath(): string {
if (!statePath) {
throw new Error("OpenPets app state has not been initialized.");
}
return statePath;
}
function getInitializedState(): OpenPetsStateV1 {
if (!currentState) {
throw new Error("OpenPets app state has not been initialized.");
}
return currentState;
}
function readStateFile(path: string): unknown {
if (!existsSync(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
console.error(`Failed to read OpenPets state from ${path}; using defaults.`, error);
return undefined;
}
}
function normalizeState(value: unknown): OpenPetsStateV1 {
const record = isRecord(value) ? value : {};
const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {};
const preferencesRecord = isRecord(record.preferences) ? record.preferences : {};
const defaultState = createDefaultState();
const position = normalizeMaybePosition(defaultPetRecord.position);
const installedPets = normalizeInstalledPets(record);
const defaultPetId = typeof preferencesRecord.defaultPetId === "string"
&& installedPets.some((pet) => pet.id === preferencesRecord.defaultPetId && !pet.broken)
? preferencesRecord.defaultPetId
: builtInPet.id;
return {
version: 1,
preferences: normalizePreferences({
...defaultState.preferences,
...preferencesRecord,
defaultPetId,
}),
pets: {
installed: installedPets,
},
defaultPet: position ? { position } : {},
};
}
function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1["preferences"] {
const defaultState = createDefaultState();
return {
defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id,
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
? value.openDefaultPetOnLaunch
: defaultState.preferences.openDefaultPetOnLaunch,
speechBubblesEnabled: true,
petScale: normalizePetScale(value.petScale),
onboardingCompleted: normalizeOnboardingCompleted(value),
};
}
function normalizeInstalledPets(value: Record<string, unknown>): InstalledPetState[] {
const installed = isRecord(value.pets) && Array.isArray(value.pets.installed)
? value.pets.installed
: [];
const normalized = installed
.map((pet) => normalizeInstalledPet(pet))
.filter((pet): pet is InstalledPetState => Boolean(pet && pet.id !== builtInPet.id));
return [builtInPet, ...normalized];
}
function normalizeInstalledPet(value: unknown): InstalledPetState | null {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string") {
return null;
}
try {
assertSafePetId(value.id);
} catch {
return null;
}
const brokenReason = validateInstalledPetFiles(value.id);
return {
id: value.id,
displayName: value.displayName,
description: typeof value.description === "string" ? value.description : undefined,
builtIn: value.id === builtInPet.id ? true : value.builtIn === true,
protected: value.id === builtInPet.id ? true : value.protected === true,
installed: true,
source: normalizeSource(value.source),
broken: brokenReason ? true : typeof value.broken === "boolean" ? value.broken : undefined,
brokenReason: brokenReason ?? (typeof value.brokenReason === "string" ? value.brokenReason : undefined),
};
}
function createDefaultState(): OpenPetsStateV1 {
return {
version: 1,
preferences: {
defaultPetId: builtInPet.id,
openDefaultPetOnLaunch: true,
speechBubblesEnabled: true,
petScale: defaultPetScale,
onboardingCompleted: false,
},
pets: {
installed: [builtInPet],
},
defaultPet: {},
};
}
function commitState(nextState: OpenPetsStateV1): void {
writeStateToDisk(nextState);
currentState = nextState;
}
function writeStateToDisk(state: OpenPetsStateV1): void {
const path = getStateFilePath();
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
}
function validateInstalledPetFiles(petId: string): string | undefined {
try {
const dir = getInstalledPetDir(petId);
const petJsonPath = join(dir, "pet.json");
const spritesheetPath = join(dir, "spritesheet.webp");
JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown;
const spritesheet = statSync(spritesheetPath);
if (!spritesheet.isFile()) return "spritesheet.webp is not a file.";
if (spritesheet.size <= 0) return "spritesheet.webp is empty.";
if (spritesheet.size > 100 * 1024 * 1024) return "spritesheet.webp is too large.";
return undefined;
} catch (error) {
return error instanceof Error ? error.message : "Installed pet files are invalid.";
}
}
function normalizeSource(value: unknown): InstalledPetState["source"] | undefined {
if (!isRecord(value)) {
return undefined;
}
if (value.kind === "codex" && typeof value.path === "string") {
return { kind: "codex", path: value.path };
}
if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined;
return {
kind: "catalog",
catalogVersion: 2,
zip: value.zip,
preview: value.preview,
};
}
function normalizeMaybePosition(value: unknown): Point | undefined {
if (!isRecord(value)) {
return undefined;
}
return normalizePosition(value);
}
function normalizePosition(value: Partial<Point>): Point | undefined {
if (typeof value.x !== "number" || typeof value.y !== "number") {
return undefined;
}
if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) {
return undefined;
}
return {
x: Math.round(value.x),
y: Math.round(value.y),
};
}
function cloneState(state: OpenPetsStateV1): OpenPetsStateV1 {
return structuredClone(state) as OpenPetsStateV1;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

110
apps/desktop/src/assets.ts Normal file
View file

@ -0,0 +1,110 @@
import { join } from "node:path";
import { app, nativeImage, type NativeImage } from "electron";
const trayIconRelativePath = join("assets", "tray-icon.png");
export function createTrayIcon(): NativeImage {
const assetPath = join(app.getAppPath(), trayIconRelativePath);
const assetImage = nativeImage.createFromPath(assetPath);
if (!assetImage.isEmpty()) {
return assetImage.resize({ width: 22, height: 22 });
}
console.error(`OpenPets tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
return createFallbackTrayIcon();
}
function createFallbackTrayIcon(): NativeImage {
const size = 32;
const bitmap = Buffer.alloc(size * size * 4, 0);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if (isRoundedSquarePixel(x, y, size, 8)) {
setPixel(bitmap, size, x, y, 17, 24, 39, 255);
}
}
}
drawCircle(bitmap, size, 11, 13, 3, 249, 250, 251, 255);
drawCircle(bitmap, size, 21, 13, 3, 249, 250, 251, 255);
drawCircle(bitmap, size, 11, 13, 1, 17, 24, 39, 255);
drawCircle(bitmap, size, 21, 13, 1, 17, 24, 39, 255);
for (let x = 10; x <= 22; x += 1) {
const y = Math.round(21 + Math.sin((x - 10) / 12 * Math.PI) * 3);
drawCircle(bitmap, size, x, y, 1, 249, 250, 251, 255);
}
const image = nativeImage.createFromBitmap(bitmap, {
width: size,
height: size,
scaleFactor: 1,
});
if (image.isEmpty()) {
console.error("OpenPets tray icon creation produced an empty image.");
}
if (process.platform === "darwin") {
image.setTemplateImage(true);
}
return image;
}
function isRoundedSquarePixel(x: number, y: number, size: number, radius: number): boolean {
const left = radius;
const right = size - radius - 1;
const top = radius;
const bottom = size - radius - 1;
if (x >= left && x <= right) return true;
if (y >= top && y <= bottom) return true;
const cornerX = x < left ? left : right;
const cornerY = y < top ? top : bottom;
const distanceSquared = (x - cornerX) ** 2 + (y - cornerY) ** 2;
return distanceSquared <= radius ** 2;
}
function drawCircle(
bitmap: Buffer,
size: number,
centerX: number,
centerY: number,
radius: number,
red: number,
green: number,
blue: number,
alpha: number,
): void {
for (let y = centerY - radius; y <= centerY + radius; y += 1) {
for (let x = centerX - radius; x <= centerX + radius; x += 1) {
if (x < 0 || y < 0 || x >= size || y >= size) continue;
if ((x - centerX) ** 2 + (y - centerY) ** 2 <= radius ** 2) {
setPixel(bitmap, size, x, y, red, green, blue, alpha);
}
}
}
}
function setPixel(
bitmap: Buffer,
size: number,
x: number,
y: number,
red: number,
green: number,
blue: number,
alpha: number,
): void {
const offset = (y * size + x) * 4;
bitmap[offset] = blue;
bitmap[offset + 1] = green;
bitmap[offset + 2] = red;
bitmap[offset + 3] = alpha;
}

View file

@ -0,0 +1,7 @@
export const builtInPet = {
id: "builtin",
displayName: "Built-in Pet",
builtIn: true,
protected: true,
installed: true,
} as const;

View file

@ -0,0 +1,80 @@
export interface CatalogV2 {
readonly version: 2;
readonly generatedAt: string;
readonly pets: readonly CatalogPetV2[];
}
export interface CatalogPetV2 {
readonly id: string;
readonly displayName: string;
readonly description: string;
readonly preview: string;
readonly zip: string;
}
export function validateCatalogV2(value: unknown): CatalogV2 {
if (!isRecord(value)) throw new Error("Catalog must be an object.");
if (value.version !== 2) throw new Error("Catalog version must be 2.");
if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog generatedAt must be a valid date string.");
if (!Array.isArray(value.pets)) throw new Error("Catalog pets must be an array.");
if (value.pets.length > 1000) throw new Error("Catalog has too many pets.");
const ids = new Set<string>();
const pets = value.pets.map((pet) => validateCatalogPet(pet, ids));
return {
version: 2,
generatedAt: value.generatedAt,
pets,
};
}
function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
if (!isRecord(value)) throw new Error("Catalog pet must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog pet id: ${id}`);
ids.add(id);
return {
id,
displayName: validateString(value.displayName, "displayName", 120),
description: validateString(value.description, "description", 500),
preview: validateCatalogUrl(value.preview, "preview"),
zip: validateCatalogUrl(value.zip, "zip"),
};
}
function validateId(value: unknown): string {
if (typeof value !== "string") throw new Error("Catalog pet id must be a string.");
if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved.");
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog pet id: ${value}`);
return value;
}
function validateString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`Catalog pet ${field} must be a string.`);
if (value.length > maxLength) throw new Error(`Catalog pet ${field} is too long.`);
return value;
}
function validateCatalogUrl(value: unknown, field: "preview" | "zip"): string {
const raw = validateString(value, field, 2048);
const url = new URL(raw);
if (url.protocol !== "https:") throw new Error(`${field} URL must use https.`);
if (url.username || url.password) throw new Error(`${field} URL cannot include credentials.`);
if (url.port) throw new Error(`${field} URL cannot include a custom port.`);
if (field === "preview") {
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed.");
} else if (url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/pets/")) {
throw new Error("Zip URL host/path is not allowed.");
}
return url.toString();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

116
apps/desktop/src/catalog.ts Normal file
View file

@ -0,0 +1,116 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { validateCatalogV2, type CatalogPetV2, type CatalogV2 } from "./catalog-validation.js";
export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json";
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const fetchTimeoutMs = 5_000;
export interface CatalogUiState {
readonly source: "remote" | "fixture" | "error";
readonly pets: readonly CatalogPetV2[];
readonly generatedAt?: string;
readonly error?: string;
}
export async function getCatalogUiState(): Promise<CatalogUiState> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) {
return {
source: "remote",
pets: remote.catalog.pets,
generatedAt: remote.catalog.generatedAt,
};
}
const fixture = await tryLoadFixtureCatalog();
if (fixture.ok) {
return {
source: "fixture",
pets: fixture.catalog.pets,
generatedAt: fixture.catalog.generatedAt,
error: `Live catalog unavailable: ${remote.error}`,
};
}
return {
source: "error",
pets: [],
error: `Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
};
}
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(catalogUrl, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateCatalogEndpoint(response.url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await readLimitedResponse(response);
return { ok: true, catalog: validateCatalogV2(JSON.parse(text) as unknown) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
} finally {
clearTimeout(timeout);
}
}
async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function loadFixtureCatalog(): Promise<unknown> {
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
}
async function readLimitedResponse(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxCatalogBytes) throw new Error("Catalog response is too large.");
chunks.push(value);
}
return new TextDecoder().decode(concatChunks(chunks, total));
}
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function validateCatalogEndpoint(value: string): void {
const url = new URL(value);
if (url.href !== catalogUrl) throw new Error("Catalog final URL is not allowed.");
}

View file

@ -0,0 +1,30 @@
import { readFile } from "node:fs/promises";
import { validateCatalogV2 } from "./catalog-validation.js";
const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown;
validateCatalogV2(fixture);
const invalidCases: readonly unknown[] = [
{ version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "Bad ID", displayName: "Bad", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "dup", displayName: "Dup", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }, { id: "dup", displayName: "Dup 2", description: "", preview: "https://openpets.dev/pets/y/spritesheet.webp", zip: "https://zip.openpets.dev/pets/y/y.zip" }] },
{ version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "http", displayName: "Http", description: "", preview: "http://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "host", displayName: "Host", description: "", preview: "https://evil.example/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "builtin", displayName: "Builtin", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
];
for (const invalidCase of invalidCases) {
assertRejectsCatalog(invalidCase);
}
console.log("Catalog fixture validation passed.");
function assertRejectsCatalog(value: unknown): void {
try {
validateCatalogV2(value);
} catch {
return;
}
throw new Error("Invalid catalog fixture case was accepted.");
}

View file

@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ensureImportLine, ensureManagedImport, installClaudeOpenPetsMemory, openPetsClaudeImportLine, removeImportLine, removeOpenPetsMemoryBlock, uninstallClaudeOpenPetsMemory, upsertOpenPetsMemoryBlock } from "./claude-memory.js";
assert.equal(ensureImportLine("", openPetsClaudeImportLine), `${openPetsClaudeImportLine}\n`);
assert.equal(ensureImportLine("# User notes\n", openPetsClaudeImportLine), `# User notes\n\n${openPetsClaudeImportLine}\n`);
assert.equal(ensureImportLine(`# User notes\n${openPetsClaudeImportLine}\n${openPetsClaudeImportLine}\n`, openPetsClaudeImportLine), `# User notes\n\n${openPetsClaudeImportLine}\n`);
assert.equal(removeImportLine(`# User notes\n\n${openPetsClaudeImportLine}\n`, openPetsClaudeImportLine), "# User notes\n");
assert.match(upsertOpenPetsMemoryBlock("custom\n", "<!-- OPENPETS:START -->\nmanaged\n<!-- OPENPETS:END -->\n"), /custom[\s\S]*managed/);
assert.equal((upsertOpenPetsMemoryBlock("<!-- OPENPETS:START -->\nold\n<!-- OPENPETS:END -->\n\n<!-- OPENPETS:START -->\nolder\n<!-- OPENPETS:END -->\n", "<!-- OPENPETS:START -->\nnew\n<!-- OPENPETS:END -->\n").match(/OPENPETS:START/g) ?? []).length, 1);
assert.match(ensureManagedImport("# User notes\n"), /OPENPETS:IMPORT:START[\s\S]*@~\/\.claude\/openpets\.md[\s\S]*OPENPETS:IMPORT:END/);
assert.equal(ensureManagedImport(`${openPetsClaudeImportLine}\n`), `${openPetsClaudeImportLine}\n`, "user-owned import line should not be wrapped as managed.");
assert.equal(removeOpenPetsMemoryBlock("custom\n<!-- OPENPETS:START -->\nmanaged\n<!-- OPENPETS:END -->\n"), "custom\n");
const dir = mkdtempSync(join(tmpdir(), "openpets-claude-memory-"));
try {
const claudeDir = join(dir, ".claude");
const claudeMd = join(claudeDir, "CLAUDE.md");
const openpetsMd = join(claudeDir, "openpets.md");
mkdirSync(claudeDir);
writeFileSync(claudeMd, "# Existing Claude instructions\n\nKeep this.\n", "utf8");
const installed = installClaudeOpenPetsMemory(dir);
assert.equal(installed.changed, true);
assert.match(readFileSync(claudeMd, "utf8"), /Keep this\.[\s\S]*@~\/\.claude\/openpets\.md/);
assert.match(readFileSync(openpetsMd, "utf8"), /openpets_say/);
const reinstalled = installClaudeOpenPetsMemory(dir);
assert.equal(reinstalled.changed, false);
assert.equal((readFileSync(claudeMd, "utf8").match(/@~\/\.claude\/openpets\.md/g) ?? []).length, 1);
assert.match(readFileSync(claudeMd, "utf8"), /OPENPETS:IMPORT:START/);
writeFileSync(openpetsMd, `${readFileSync(openpetsMd, "utf8")}\nUser custom note.\n`, "utf8");
const uninstalled = uninstallClaudeOpenPetsMemory(dir);
assert.equal(uninstalled.changed, true);
assert.doesNotMatch(readFileSync(claudeMd, "utf8"), /openpets\.md/);
assert.equal(existsSync(openpetsMd), true, "customized openpets.md should be preserved after managed block removal.");
assert.match(readFileSync(openpetsMd, "utf8"), /User custom note/);
const userImportHome = join(dir, "user-import-home");
const userClaudeDir = join(userImportHome, ".claude");
mkdirSync(userClaudeDir, { recursive: true });
writeFileSync(join(userClaudeDir, "CLAUDE.md"), `# User-owned import\n${openPetsClaudeImportLine}\n`, "utf8");
writeFileSync(join(userClaudeDir, "openpets.md"), "User-owned content.\n", "utf8");
installClaudeOpenPetsMemory(userImportHome);
assert.doesNotMatch(readFileSync(join(userClaudeDir, "CLAUDE.md"), "utf8"), /OPENPETS:IMPORT:START/, "pre-existing import should remain user-owned.");
uninstallClaudeOpenPetsMemory(userImportHome);
assert.match(readFileSync(join(userClaudeDir, "CLAUDE.md"), "utf8"), /@~\/\.claude\/openpets\.md/, "user-owned import should not be removed.");
assert.match(readFileSync(join(userClaudeDir, "openpets.md"), "utf8"), /User-owned content/, "user-owned openpets.md content should be preserved.");
const symlinkHome = join(dir, "symlink-home");
const symlinkTarget = join(dir, "outside");
mkdirSync(symlinkHome);
mkdirSync(symlinkTarget);
symlinkSync(symlinkTarget, join(symlinkHome, ".claude"));
assert.throws(() => installClaudeOpenPetsMemory(symlinkHome));
const symlinkFileHome = join(dir, "symlink-file-home");
mkdirSync(join(symlinkFileHome, ".claude"), { recursive: true });
writeFileSync(join(dir, "outside-file"), "x", "utf8");
symlinkSync(join(dir, "outside-file"), join(symlinkFileHome, ".claude", "CLAUDE.md"));
assert.throws(() => installClaudeOpenPetsMemory(symlinkFileHome));
const oversizedHome = join(dir, "oversized-home");
mkdirSync(join(oversizedHome, ".claude"), { recursive: true });
writeFileSync(join(oversizedHome, ".claude", "CLAUDE.md"), "x".repeat(1024 * 1024 + 1), "utf8");
assert.throws(() => installClaudeOpenPetsMemory(oversizedHome));
} finally {
rmSync(dir, { recursive: true, force: true });
}
console.error("Claude memory validation passed.");

View file

@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { canInlineCodexPreview, maxCodexPets, maxCodexPreviewBytes, maxCodexTotalPreviewBytes, validateCodexPetMetadata } from "./codex-pets-core.js";
const valid = validateCodexPetMetadata({
id: "fixer",
displayName: " Fixer ",
description: " Repairs things. ",
spritesheetPath: "spritesheet.webp",
}, "fixer");
assert.deepEqual(valid, {
id: "fixer",
displayName: "Fixer",
description: "Repairs things.",
spritesheetPath: "spritesheet.webp",
});
assert.throws(() => validateCodexPetMetadata({ id: "other", displayName: "Other", description: "Nope", spritesheetPath: "spritesheet.webp" }, "fixer"));
assert.throws(() => validateCodexPetMetadata({ id: "builtin", displayName: "Built-in", description: "Reserved", spritesheetPath: "spritesheet.webp" }, "builtin"));
assert.throws(() => validateCodexPetMetadata({ id: "bad/id", displayName: "Bad", description: "Bad", spritesheetPath: "spritesheet.webp" }, "bad/id"));
assert.throws(() => validateCodexPetMetadata({ id: "fixer", displayName: "Fixer", description: "Nope", spritesheetPath: "../spritesheet.webp" }, "fixer"));
assert.throws(() => validateCodexPetMetadata({ id: "fixer", displayName: "", description: "Nope", spritesheetPath: "spritesheet.webp" }, "fixer"));
assert.equal(canInlineCodexPreview(0), false);
assert.equal(canInlineCodexPreview(maxCodexPreviewBytes), true);
assert.equal(canInlineCodexPreview(maxCodexPreviewBytes + 1), false);
assert.equal(canInlineCodexPreview(Number.POSITIVE_INFINITY), false);
assert.equal(maxCodexTotalPreviewBytes, maxCodexPreviewBytes * 3);
assert.equal(maxCodexPets, 100);
console.error("Codex pet validation passed.");

View file

@ -0,0 +1,60 @@
import { LeaseManager } from "./lease-manager.js";
let now = 1_000;
const opened: string[] = [];
const closed: string[] = [];
const manager = new LeaseManager({
ttlMs: 100,
now: () => now,
resolveTarget: (requestedPetId) => {
if (!requestedPetId) return { targetKind: "default", actualPetId: "builtin" };
if (requestedPetId === "missing") return { targetKind: "default", actualPetId: "builtin", fallbackReason: "pet_not_installed" };
return { targetKind: "explicit", actualPetId: requestedPetId };
},
getDefaultPetId: () => "builtin",
getPetDisplayName: (petId) => petId,
onFirstExplicitLease: (petId) => opened.push(petId),
onLastExplicitLease: (petId) => closed.push(petId),
});
const defaultLease = manager.acquire();
if (!defaultLease.usingDefaultPet || defaultLease.targetKind !== "default") throw new Error("Default lease did not target default.");
manager.release(defaultLease.leaseId);
if (closed.length !== 0) throw new Error("Default release closed a temp pet.");
const first = manager.acquire("snoopy");
const second = manager.acquire("snoopy");
if (opened.join(",") !== "snoopy") throw new Error("Explicit pet did not open once for multiple leases.");
manager.release(first.leaseId);
if (closed.length !== 0) throw new Error("Explicit pet closed before final lease release.");
manager.release(first.leaseId);
manager.release(second.leaseId);
if (closed.join(",") !== "snoopy") throw new Error("Explicit pet did not close after final release.");
const missing = manager.acquire("missing");
if (missing.fallbackReason !== "pet_not_installed" || !missing.usingDefaultPet) throw new Error("Missing pet did not fall back to default.");
manager.release(missing.leaseId);
const expiring = manager.acquire("tux");
now += 50;
manager.heartbeat(expiring.leaseId);
now += 75;
if (manager.cleanupExpired().length !== 0) throw new Error("Heartbeat did not extend lease.");
now += 50;
if (manager.cleanupExpired().length !== 1) throw new Error("Expired lease was not cleaned up.");
const expiredBeforeHeartbeat = manager.acquire("dobby");
now += 200;
assertRejects(() => manager.heartbeat(expiredBeforeHeartbeat.leaseId));
if (manager.get(expiredBeforeHeartbeat.leaseId) !== null) throw new Error("Expired lease was still readable before cleanup.");
console.log("Lease manager validation passed.");
function assertRejects(callback: () => unknown): void {
try {
callback();
} catch {
return;
}
throw new Error("Expected lease operation to reject.");
}

View file

@ -0,0 +1,55 @@
import { errorResponse, maxIpcMessageBytes, parseIpcRequest, validateReaction, validateSayMessage } from "./local-ipc-protocol.js";
const token = "test-token";
const valid = {
id: "1",
version: 1,
token,
method: "status",
params: {},
};
parseIpcRequest(JSON.stringify(valid), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "pets.list" }), token);
assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, token: "bad" }), token));
assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, version: 2 }), token));
assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, method: "pet.install" }), token));
assertRejects(() => parseIpcRequest("not json", token));
validateReaction("testing");
validateReaction("waving");
assertRejects(() => validateReaction("bad"));
validateSayMessage("Working on it");
for (const unsafe of [
"",
"a".repeat(141),
"line one\nline two",
"```code```",
"const secret = 1",
"https://example.com",
"/Users/alvin/project/file.ts",
"api_key=abc123",
]) {
assertRejects(() => validateSayMessage(unsafe));
}
if (Buffer.byteLength(JSON.stringify({ message: "x".repeat(maxIpcMessageBytes) }), "utf8") <= maxIpcMessageBytes) {
throw new Error("Oversized fixture was not oversized.");
}
const response = errorResponse("1", new Error("boom"));
if (response.ok || response.error?.code !== "internal_error") {
throw new Error("Failed to create structured error response.");
}
console.log("Local IPC protocol validation passed.");
function assertRejects(callback: () => unknown): void {
try {
callback();
} catch {
return;
}
throw new Error("Expected validation to reject.");
}

View file

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions } from "./app-state-core.js";
assert.equal(normalizeOnboardingCompleted({}), false);
assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: true }), true);
assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: false }), false);
assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: "true" }), false);
const state = {
version: 1,
preferences: {
defaultPetId: "built-in",
openDefaultPetOnLaunch: true,
speechBubblesEnabled: true,
petScale: 1,
onboardingCompleted: false,
},
pets: {
installed: [{ id: "built-in", displayName: "Built-in Pet" }],
},
};
const completed = markOnboardingCompleted(state);
assert.equal(completed.preferences.onboardingCompleted, true);
assert.equal(completed.preferences.defaultPetId, "built-in");
assert.equal(completed.preferences.openDefaultPetOnLaunch, true);
assert.equal(completed.preferences.speechBubblesEnabled, true);
assert.equal(completed.preferences.petScale, 1);
assert.deepEqual(completed.pets, state.pets);
assert.equal(state.preferences.onboardingCompleted, false);
const preferencePatch = {
...completed.preferences,
speechBubblesEnabled: true,
};
assert.equal(normalizeOnboardingCompleted(preferencePatch), true);
assert.equal(preferencePatch.defaultPetId, "built-in");
assert.equal(preferencePatch.openDefaultPetOnLaunch, true);
assert.equal(preferencePatch.speechBubblesEnabled, true);
assert.equal(defaultPetScale, 0.56);
assert.deepEqual(petScaleOptions.map((option) => option.value), [0.44, 0.56, 0.72]);
assert.equal(normalizePetScale(0.44), 0.44);
assert.equal(normalizePetScale(0.56), 0.56);
assert.equal(normalizePetScale(0.72), 0.72);
assert.equal(normalizePetScale(1), defaultPetScale);
assert.equal(normalizePetScale("0.56"), defaultPetScale);
assert.equal(normalizePetScale(Number.NaN), defaultPetScale);
assert.equal(normalizePetScale(Number.POSITIVE_INFINITY), defaultPetScale);
assert.equal(normalizePetScale(undefined), defaultPetScale);
console.error("Onboarding state validation passed.");

View file

@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mapAsarPathToUnpacked } from "@open-pets/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
const root = mkdtempSync(join(tmpdir(), "openpets-desktop-opencode-"));
try {
const globalDir = join(root, "opencode-global");
const bundledCli = mapAsarPathToUnpacked(join(root, "OpenPets.app", "Contents", "Resources", "app.asar", "node_modules", "@open-pets", "cli", "dist", "index.js"));
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed");
const install = prepareOpenCodeGlobalSetup({
configDir: globalDir,
petId: "fixer",
cliVersion: "1.2.3",
pluginVersion: "4.5.6",
commandMode: "bundled",
cliEntryPath: bundledCli,
});
assert.equal(install.configPath, join(globalDir, "opencode.jsonc"));
assert.equal(install.instructionPath, join(globalDir, "openpets.md"));
assert.match(install.configWrite.content, /app\.asar\.unpacked/);
assert.doesNotMatch(install.configWrite.content, /app\.asar(?!\.unpacked)/);
assert.match(install.configWrite.content, /@open-pets\/opencode@4\.5\.6/);
const preview = parseOpenCodeConfig(install.configWrite.content);
assert.equal(preview.ok, true, "desktop OpenCode preview must parse as JSONC without JSON.parse.");
const previewConfig = preview.value as { readonly mcp?: { readonly openpets?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] };
assert.deepEqual(previewConfig.mcp?.openpets?.command, ["node", bundledCli, "mcp", "--pet", "fixer"]);
assert.deepEqual(previewConfig.plugin, [["@open-pets/opencode@4.5.6", { pet: "fixer" }]]);
writePreparedOpenCodeGlobalSetup(install);
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "installed");
assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/);
const remove = prepareOpenCodeGlobalRemove(globalDir);
assert.equal(remove.configWrites.length, 1);
writePreparedOpenCodeGlobalRemove(remove);
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed");
const commentedGlobalDir = join(root, "commented-global");
mkdirSync(commentedGlobalDir);
writeFileSync(join(commentedGlobalDir, "opencode.jsonc"), `{
// user comment must not block desktop preview planning
"theme": "dark"
}\n`, "utf8");
const commented = prepareOpenCodeGlobalSetup({ configDir: commentedGlobalDir, petId: "fixer", cliVersion: "1.2.3", pluginVersion: "4.5.6" });
assert.equal(parseOpenCodeConfig(commented.configWrite.content).ok, true);
assert.match(commented.configWrite.content, /user comment/);
} finally {
rmSync(root, { recursive: true, force: true });
}
console.error("OpenCode desktop setup validation passed.");

View file

@ -0,0 +1,340 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { allowedReactions } from "./local-ipc-protocol.js";
import { pickReactionMessage, reactionMessagePools } from "./reaction-messages.js";
const distDir = dirname(fileURLToPath(import.meta.url));
const appDir = dirname(distDir);
const repoRoot = resolve(appDir, "../..");
const packageJson = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as { scripts?: Record<string, string>; dependencies?: Record<string, string>; description?: string; author?: string };
const rootPackageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { scripts?: Record<string, string> };
const builderConfigPath = join(appDir, "electron-builder.yml");
const builderConfig = readFileSync(builderConfigPath, "utf8");
assert.equal(packageJson.description, "OpenPets tray-first desktop companion app.");
assert.equal(packageJson.author, "OpenPets");
assert.match(packageJson.scripts?.package ?? "", /node scripts\/clean-package-output\.cjs && electron-builder/);
assert.match(packageJson.scripts?.["package:dir"] ?? "", /node scripts\/clean-package-output\.cjs && electron-builder --dir/);
assert.equal(rootPackageJson.scripts?.["package:desktop:dir"], "pnpm build && pnpm --filter @open-pets/desktop package:dir");
assert.equal(packageJson.dependencies?.["@open-pets/claude"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/cli"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/mcp"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/opencode"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/agent-events"], "workspace:*");
assert.match(builderConfig, /appId:\s*dev\.openpets\.app/);
assert.match(builderConfig, /productName:\s*OpenPets/);
assert.match(builderConfig, /output:\s*dist-electron/);
assert.match(builderConfig, /publish:\s*null/);
assert.match(builderConfig, /asar:\s*true/);
assert.match(builderConfig, /asarUnpack:/);
assert.match(builderConfig, /node_modules\/\*\*/);
assert.match(builderConfig, /dist\/\*\*/);
assert.match(builderConfig, /preload\.cjs/);
assert.match(builderConfig, /pet-preload\.cjs/);
assert.match(builderConfig, /assets\/\*\*/);
assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/);
assert.ok(existsSync(join(appDir, "preload.cjs")), "preload.cjs must exist for packaging.");
assert.ok(existsSync(join(appDir, "pet-preload.cjs")), "pet-preload.cjs must exist for pet window motion state updates.");
assert.ok(existsSync(join(appDir, "assets", "tray-icon.png")), "tray icon must exist for packaging.");
assert.ok(existsSync(join(appDir, "assets", "app-icon.icns")), "app icon must exist for packaging.");
assert.ok(existsSync(join(appDir, "assets", "app-icon.ico")), "Windows app icon must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-spritesheet.webp"), "default pet spritesheet must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-thumbnail.png"), "default pet thumbnail must exist for Pet Manager preview.");
assertNonEmptyFile(join(appDir, "assets", "onboarding-logo.webp"), "onboarding logo asset must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "onboarding-pets.webp"), "onboarding pet scene asset must exist for packaging.");
for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "vscode.svg", "windsurf.svg", "zed.svg"]) {
assertSafeBundledSvg(join(appDir, "assets", "integrations", icon), `integration icon must be safe and packaged: ${icon}`);
}
assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png.");
const petWindowSource = readFileSync(join(appDir, "src", "pet-window.ts"), "utf8");
const petPreloadSource = readFileSync(join(appDir, "pet-preload.cjs"), "utf8");
const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8");
const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8");
const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"), "utf8");
const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8");
const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8");
const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8");
const preloadSource = readFileSync(join(appDir, "preload.cjs"), "utf8");
const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8");
assert.match(petWindowSource, /default-pet-spritesheet\.webp/, "default pet renderer must reference the bundled WebP spritesheet asset.");
assert.match(petWindowSource, /columns:\s*8/, "default pet renderer must keep the real catalog sprite sheet column count.");
assert.match(petWindowSource, /rows:\s*9/, "default pet renderer must keep the real catalog sprite sheet row count.");
assert.match(petWindowSource, /frameWidth:\s*192/, "default pet renderer must keep the universal Codex frame width.");
assert.match(petWindowSource, /frameHeight:\s*208/, "default pet renderer must keep the universal Codex frame height.");
assert.ok(petWindowSource.includes("defaultPetSprite.frameWidth * defaultPetSprite.columns") && petWindowSource.includes("defaultPetSprite.frameHeight * defaultPetSprite.rows"), "pet renderer must derive universal spritesheet dimensions from frame size and row/column counts.");
for (const state of ["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]) {
assert.match(petWindowSource, new RegExp(`["']?${state}["']?:\\s*\\{\\s*row:`), `pet renderer must define universal sprite state: ${state}`);
}
for (const reaction of ["idle", "thinking", "working", "editing", "running", "testing", "waiting", "waving", "success", "error", "celebrating"]) {
assert.match(petWindowSource, new RegExp(`${reaction}:\\s*["']`), `pet renderer must map reaction to sprite state: ${reaction}`);
assert.match(reactionMessagesSource, new RegExp(`${reaction}:\\s*\\[`), `reaction messages must define a pool for: ${reaction}`);
}
assert.match(petWindowSource, /satisfies Record<OpenPetsReaction, UniversalSpriteState>/, "reaction-to-sprite-state mapping must be exhaustive over OpenPetsReaction.");
assert.match(reactionMessagesSource, /satisfies Record<OpenPetsReaction, readonly string\[\]>/, "reaction-only bubble message pools must be exhaustive over OpenPetsReaction.");
assert.match(petWindowSource, /pickReactionMessage\(display\.reaction\)/, "reaction-only bubbles must render randomized messages instead of raw lowercase reaction ids.");
assert.match(petWindowSource, /function preparePetTransientDisplay/, "reaction-only bubbles must prepare a stable random message before rerenders.");
assert.match(petWindowSource, /function mergePetTransientDisplay/, "reaction-only events must not replace an active explicit message bubble.");
assert.match(petWindowSource, /function getTransientReactionAnimationMs/, "finite reaction animations must expose their own shorter lifetime.");
assert.match(petWindowSource, /function clearTransientReaction/, "finite reaction animations must be clearable while the bubble remains visible.");
assert.match(petWindowSource, /webContents\.send\("openpets:pet-reaction-state"/, "finite reaction animations must clear sprite state without reloading the bubble.");
assert.match(petPreloadSource, /openpets:pet-reaction-state/, "pet preload must accept in-place reaction state updates.");
assert.match(displaySource, /width:\s*220/, "pet windows must stay tightly bounded around pet and bubble.");
assert.match(displaySource, /height:\s*320/, "pet windows must be tall enough for adaptive long message bubbles at large pet scale without becoming a huge click shield.");
assert.match(petWindowSource, /function getBubbleClassName/, "pet bubbles must classify explicit messages by length.");
assert.match(petWindowSource, /is-long-message/, "pet bubbles must have a long-message layout.");
assert.match(petWindowSource, /is-very-long-message/, "pet bubbles must have a very-long-message layout for 140-character say messages.");
assert.match(petWindowSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent pet window background must not capture clicks or drags.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "pet windows must install real mouse passthrough and controlled drag behavior.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent pet window background must use OS-level mouse passthrough.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible pet and bubble hit targets must re-enable mouse handling.");
assert.match(petWindowSource, /\.pet-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "pet dragging must avoid Electron draggable regions so right-click context menus work.");
assert.match(petPreloadSource, /openpets:pet-hit-test/, "pet preload must report visible pet and bubble hit testing for passthrough.");
assert.match(petPreloadSource, /openpets:pet-drag-start/, "pet preload must start controlled pet dragging from the sprite.");
assert.match(petWindowSource, /function installPetContextMenu/, "pet windows must install a native right-click context menu.");
assert.match(petWindowSource, /webContents\.on\("context-menu"/, "pet context menu must be handled in the Electron main process.");
assert.match(petWindowSource, /Menu\.buildFromTemplate/, "pet context menu must use a small native Electron menu.");
assert.doesNotMatch(petPreloadSource, /setIgnoreMouseEvents/, "pet preload must not call Electron window APIs directly.");
const agentPetControllerSource = readFileSync(join(appDir, "src", "agent-pet-controller.ts"), "utf8");
const localIpcSource = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
assert.match(agentPetControllerSource, /dismissedAgentPets = new Set<string>/, "agent pets must remember manual close while leases remain active.");
assert.match(agentPetControllerSource, /dismissAgentPetForActiveLease/, "agent pet context-menu close must dismiss the pet for the active lease.");
assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dismissed agent pets must not reopen on later same-lease reactions.");
assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent pet lease cleanup must clear dismissal, timers, and hidden transient state.");
assert.match(localIpcSource, /handleLastExplicitLease/, "agent pet dismissal must clear when the explicit lease group ends.");
assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent pet state.");
assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit pet events as not shown.");
assert.match(updateCheckerSource, /alvinunreal\/openpets/, "GitHub release notice must check the public OpenPets repository.");
assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API.");
assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally.");
assert.match(traySource, /Update available:/, "tray menu must surface available updates.");
assert.match(windowsSource, /openpets:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(windowsSource, /openpets:open-update-release-page/, "settings window must be able to open the release page.");
assert.match(windowsSource, /id="check-for-updates"/, "settings UI must include a Check for updates button.");
assert.match(windowsSource, /id="open-update-release"/, "settings UI must include an Open release button.");
assert.match(preloadSource, /checkForUpdates/, "settings preload must expose update checks.");
assert.match(preloadSource, /\{ label: "Thinking", state: "thinking" \}/, "Pet Manager mini previews must use a non-idle thinking/review state because the hero already shows idle.");
assert.match(preloadSource, /\{ label: "Happy", state: "happy" \}/, "Pet Manager mini previews must request the happy/jumping row.");
assert.match(preloadSource, /\{ label: "Wave", state: "wave" \}/, "Pet Manager mini previews must request the waving row.");
assert.doesNotMatch(preloadSource, /\{ label: "Idle", state: "idle" \}/, "Pet Manager mini previews must not duplicate the hero idle preview.");
assert.match(preloadSource, /pm-mini-sprite[\s\S]*animated: true/, "Pet Manager mini state previews must animate spritesheet states.");
assert.match(preloadSource, /pm-sprite-state-\$\{state\}/, "Pet Manager sprite frames must apply state-specific row classes.");
assert.match(windowsSource, /pm-sprite-state-thinking \{ background-position: 0 100%; \}/, "Pet Manager thinking preview must use universal spritesheet row 8.");
assert.match(windowsSource, /pm-sprite-state-wave \{ background-position: 0 37\.5%; \}/, "Pet Manager wave preview must use universal spritesheet row 3.");
assert.match(windowsSource, /pm-sprite-state-happy \{ background-position: 0 50%; \}/, "Pet Manager happy preview must use universal spritesheet row 4.");
assert.match(windowsSource, /pm-sprite-state-thinking\.pm-animate-sprite \{ animation: pm-sprite-thinking 1\.55s steps\(6\) infinite; \}/, "Pet Manager thinking preview must animate the 6-frame review row at a calm speed.");
assert.match(windowsSource, /pm-sprite-state-wave\.pm-animate-sprite \{ animation: pm-sprite-wave 1\.25s steps\(4\) infinite; \}/, "Pet Manager wave preview must animate the 4-frame waving row at a calm speed.");
assert.match(windowsSource, /pm-sprite-state-happy\.pm-animate-sprite \{ animation: pm-sprite-happy 1\.35s steps\(5\) infinite; \}/, "Pet Manager happy preview must animate the 5-frame jumping row at a calm speed.");
assert.match(windowsSource, /@keyframes pm-sprite-thinking/, "Pet Manager must define thinking row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-wave/, "Pet Manager must define wave row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-happy/, "Pet Manager must define happy row animation keyframes.");
assert.match(petWindowSource, /max-width:\s*min\(204px/, "very long message bubbles must stay capped within the tight pet window.");
assert.match(petWindowSource, /-webkit-line-clamp:\s*9/, "very long message bubbles must allow enough visible lines.");
assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state.");
assert.match(petWindowSource, /createSpriteStateCss\("\.installed-sprite"\)/, "installed sprite CSS must react to reaction state.");
assert.match(petWindowSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state.");
assert.match(petWindowSource, /\.sprite, \.installed-sprite, \.bubble/, "reduced-motion CSS must include built-in and installed sprites.");
assert.match(petWindowSource, /function createAgentPetWindow[\s\S]*?installMotionStatePublisher\(window\)/, "agent pet windows must publish motion state so dragged non-default pets run.");
assert.match(petWindowSource, /loadExplicitPetContent[\s\S]*?state\.preferences\.petScale/, "explicit agent pet windows must use the saved pet scale preference.");
assert.match(mappingDoc, /\| 3 \| `waving` \| `waving`, Claude `Notification`\. \|/, "mapping docs must describe waving animation row and notification mapping.");
assert.doesNotMatch(mappingDoc, /bubble-only|currently \*\*bubble states\*\*/i, "mapping docs must not describe reactions as bubble-only.");
for (const reaction of allowedReactions) {
const pool = reactionMessagePools[reaction];
assert.ok(pool.length >= 20, `reaction message pool must be extensive for: ${reaction}`);
for (const message of pool) {
assert.match(message, /^[A-Z]/, `reaction message must start uppercase: ${message}`);
assert.doesNotMatch(message, /[\r\n]/, `reaction message must be single-line: ${message}`);
assert.ok(message.length <= 36, `reaction message must stay bubble-friendly: ${message}`);
}
}
assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected.");
const agentSetupHtmlSource = windowsSource.match(/function createAgentSetupHtml[\s\S]*?function createSettingsHtml/)?.[0] ?? "";
assert.match(windowsSource, /onboarding-logo\.webp/, "onboarding greeting must reference the bundled OpenPets logo asset.");
assert.match(windowsSource, /onboarding-pets\.webp/, "onboarding greeting must reference the bundled pet scene asset.");
assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "onboarding image CSP must stay data-only for embedded packaged assets.");
assert.doesNotMatch(windowsSource, /data-default-pet-sprite-src|createAssetDataUrl\("default-pet-spritesheet\.webp"/, "Pet Manager must not embed the large default spritesheet into the task-window data URL.");
assert.match(windowsSource, /default-pet-thumbnail\.png/, "Pet Manager must use the small bundled default pet thumbnail for built-in preview.");
assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data: https://openpets.dev; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "Pet Manager image CSP must stay scoped to data URLs and openpets.dev catalog previews.");
assert.match(windowsSource, /petManagerWindowWidth\s*=\s*1160/, "Pet Manager should use the approved wider 1160px default window width.");
assert.match(windowsSource, /petManagerWindowHeight\s*=\s*780/, "Pet Manager should use the approved taller 780px default window height.");
assert.match(agentSetupHtmlSource, /content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"/, "Agent Setup image CSP must stay data-only for the bundled logo asset.");
assert.match(windowsSource, /integrations\/claude\.svg/, "Agent Setup integrations hub must use bundled integration SVG assets.");
assert.match(windowsSource, /integration-opencode-status/, "Agent Setup must show an enabled OpenCode integration card.");
assert.match(windowsSource, /opencode-detail-view/, "Agent Setup must include an OpenCode detail pane.");
assert.match(windowsSource, /Desktop OpenCode setup is global/, "OpenCode desktop setup must clearly warn that it is global.");
assert.match(preloadSource, /opencode-install/, "Agent Setup preload must bind OpenCode install actions.");
assert.match(preloadSource, /setIconButtonContent\(opencodeInstall, "spinner", "Installing…"\)/, "OpenCode integration-card install button must show a loading spinner.");
assert.match(preloadSource, /opencode-remove/, "Agent Setup preload must bind OpenCode remove actions.");
assert.match(preloadSource, /opencode-copy-config/, "Agent Setup preload must bind OpenCode copy-preview actions.");
assert.match(preloadSource, /opencodePreview/, "Agent Setup preload must render OpenCode previews.");
assert.match(preloadSource, /cleanupConfigPaths/, "OpenCode preview must disclose stale overlay cleanup config paths.");
assert.match(preloadSource, /configPreview/, "OpenCode preview copy must use the prepared config preview shape.");
assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse.");
assert.doesNotMatch(agentSetupHtmlSource, /https?:\/\//, "Agent Setup must not allow or reference remote integration assets.");
assert.match(windowsSource, /agentSetupWindowWidth\s*=\s*1160/, "Agent Setup should use the approved wider 1160px default window width.");
assert.match(windowsSource, /agentSetupWindowHeight\s*=\s*780/, "Agent Setup should use the approved taller 780px default window height.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "pet scale preference changes must refresh default and agent pet windows.");
assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist.");
assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run.");
assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@open-pets/claude must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "client", "dist", "index.js")), "@open-pets/client must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "mcp", "dist", "index.js")), "@open-pets/mcp must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "cli", "dist", "index.js")), "@open-pets/cli must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "opencode", "dist", "plugin.js")), "@open-pets/opencode plugin must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "agent-events", "dist", "index.js")), "@open-pets/agent-events must be built before packaging.");
if (process.argv.includes("--output")) {
checkPackageOutput();
} else {
checkCleanupHelper();
}
console.error("Packaging contract validation passed.");
function checkPackageOutput(): void {
const outputDir = join(appDir, "dist-electron");
assert.ok(existsSync(outputDir), "dist-electron output must exist after packaging.");
assertNoForbiddenOutput(outputDir);
assertNoEscapingSymlinks(outputDir);
const appResourceDir = findPackagedAppResourceDir(outputDir);
assert.ok(appResourceDir, "packaged app resources directory was not found.");
assert.ok(existsSync(join(appResourceDir, "app.asar")), "packaged app.asar is missing.");
const appContents = join(appResourceDir, "app.asar.unpacked");
assert.ok(existsSync(appContents), "packaged app.asar.unpacked resources are missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "index.js")), "packaged @open-pets/claude runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js")), "packaged @open-pets/claude CLI runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "package.json")), "packaged @open-pets/claude package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "dist", "index.js")), "packaged @open-pets/client runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "package.json")), "packaged @open-pets/client package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js")), "packaged @open-pets/mcp runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "package.json")), "packaged @open-pets/mcp package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js")), "packaged @open-pets/cli runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js")), "packaged @open-pets/opencode plugin runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "package.json")), "packaged @open-pets/opencode package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "agent-events", "dist", "index.js")), "packaged @open-pets/agent-events runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@modelcontextprotocol", "sdk")), "packaged MCP SDK runtime dependency is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "zod", "index.cjs")), "packaged zod runtime dependency is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "yauzl", "index.js")), "packaged yauzl runtime dependency is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "yauzl", "fd-slicer.js")), "packaged yauzl fd-slicer helper is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "buffer-crc32", "index.js")), "packaged yauzl transitive dependency buffer-crc32 is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "pend", "index.js")), "packaged yauzl transitive dependency pend is missing.");
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js"));
assertCommandSmoke(appContents);
}
function findPackagedAppResourceDir(outputDir: string): string | null {
const candidates: string[] = [];
collectDirectories(outputDir, candidates, 4);
for (const dir of candidates) {
if (existsSync(join(dir, "app.asar")) || existsSync(join(dir, "app", "dist", "main.js"))) {
return dir;
}
}
return null;
}
function collectDirectories(dir: string, result: string[], depth: number): void {
if (depth < 0 || !existsSync(dir)) return;
result.push(dir);
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) collectDirectories(join(dir, entry.name), result, depth - 1);
}
}
function assertNoForbiddenOutput(outputDir: string): void {
const forbiddenSegments = new Set(["v1", "web", ".env", ".claude"]);
for (const path of walk(outputDir)) {
const rel = relative(outputDir, path);
const segments = rel.split(/[\\/]/g);
assert.ok(!segments.includes("docs") || !segments.includes("phases"), `package output must not include phase docs: ${rel}`);
for (const segment of segments) {
assert.ok(!forbiddenSegments.has(segment) && !segment.startsWith(".env"), `package output contains forbidden path segment: ${rel}`);
}
}
}
function assertNoEscapingSymlinks(outputDir: string): void {
const outputReal = realpathSync(outputDir);
for (const path of walk(outputDir)) {
const stat = lstatSync(path);
if (!stat.isSymbolicLink()) continue;
const target = realpathSync(path);
assert.ok(isInside(outputReal, target), `package output symlink escapes package directory: ${relative(outputDir, path)} -> ${target}`);
}
}
function walk(dir: string): string[] {
const result: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
result.push(path);
if (entry.isDirectory()) {
result.push(...walk(path));
}
}
return result;
}
function isInside(parent: string, child: string): boolean {
const rel = relative(parent, child);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
function checkCleanupHelper(): void {
const sentinel = join(appDir, "dist-electron", ".openpets-clean-sentinel");
mkdirSync(dirname(sentinel), { recursive: true });
writeFileSync(sentinel, "stale", "utf8");
const result = spawnSync(process.execPath, [join(appDir, "scripts", "clean-package-output.cjs")], { cwd: appDir, encoding: "utf8" });
assert.equal(result.status, 0, `cleanup helper failed: ${result.stderr || result.stdout}`);
assert.ok(!existsSync(sentinel), "cleanup helper did not remove stale package output sentinel.");
}
function assertRegularNonSymlink(path: string): void {
assert.ok(!lstatSync(path).isSymbolicLink(), `packaged command file must not be a symlink: ${path}`);
assert.ok(lstatSync(path).isFile(), `packaged command file must be regular: ${path}`);
}
function assertNonEmptyFile(path: string, message: string): void {
assert.ok(existsSync(path), message);
const stat = lstatSync(path);
assert.ok(stat.isFile(), message);
assert.ok(stat.size > 0, message);
}
function assertSafeBundledSvg(path: string, message: string): void {
assertNonEmptyFile(path, message);
const source = readFileSync(path, "utf8");
assert.doesNotMatch(source, /<script\b/i, `${message}: script tags are not allowed.`);
assert.doesNotMatch(source, /\son[a-z]+\s*=/i, `${message}: event attributes are not allowed.`);
assert.doesNotMatch(source, /(?:href|xlink:href)\s*=\s*["'](?:https?:|file:|javascript:)/i, `${message}: external or script hrefs are not allowed.`);
assert.doesNotMatch(source.replace(/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/gi, ""), /https?:\/\//i, `${message}: remote references are not allowed.`);
}
function assertCommandSmoke(appContents: string): void {
const mcpEntry = join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js");
const mcp = spawnSync(process.execPath, [mcpEntry, "--version"], { encoding: "utf8" });
assert.equal(mcp.status, 0, `packaged MCP command smoke failed: ${mcp.stderr || mcp.stdout}`);
const hookEntry = join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js");
const hook = spawnSync(process.execPath, [hookEntry, "hook", "--openpets-managed"], {
input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }),
encoding: "utf8",
env: { ...process.env, OPENPETS_DISCOVERY_FILE: join(appContents, "missing-ipc.json") },
});
assert.equal(hook.status, 0, `packaged Claude hook command smoke failed: ${hook.stderr || hook.stdout}`);
assert.equal(hook.stdout, "");
const opencodePlugin = join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js");
const plugin = spawnSync(process.execPath, ["--input-type=module", "--eval", `const mod = await import(${JSON.stringify(`file://${opencodePlugin}`)}); if (!mod.default?.server || !mod.default?.id) process.exit(2);`], { encoding: "utf8" });
assert.equal(plugin.status, 0, `packaged OpenCode plugin smoke failed: ${plugin.stderr || plugin.stdout}`);
}

View file

@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { createParsedUpdateStatus, isVersionNewer, normalizeVersion } from "./update-version.js";
assert.equal(normalizeVersion("v1.2.3"), "1.2.3");
assert.equal(normalizeVersion("1.2.3"), "1.2.3");
assert.equal(normalizeVersion("v1.2.3-beta.1"), "1.2.3");
assert.equal(normalizeVersion("release"), null);
assert.equal(isVersionNewer("1.0.1", "1.0.0"), true);
assert.equal(isVersionNewer("1.2.0", "1.1.9"), true);
assert.equal(isVersionNewer("2.0.0", "1.99.99"), true);
assert.equal(isVersionNewer("1.0.0", "1.0.0"), false);
assert.equal(isVersionNewer("1.0.0", "1.0.1"), false);
assert.equal(isVersionNewer("bad", "1.0.1"), false);
const repo = "alvinunreal/openpets";
const releasesUrl = `https://github.com/${repo}/releases`;
const available = createParsedUpdateStatus("1.0.0", { tag_name: "v1.0.1", html_url: "https://github.com/alvinunreal/openpets/releases/tag/v1.0.1" }, 123, repo, releasesUrl);
assert.equal(available.state, "available");
assert.equal(available.latestVersion, "1.0.1");
const current = createParsedUpdateStatus("1.0.1", { tag_name: "v1.0.1", html_url: "https://github.com/alvinunreal/openpets/releases/tag/v1.0.1" }, 124, repo, releasesUrl);
assert.equal(current.state, "current");
assert.throws(() => createParsedUpdateStatus("1.0.0", { tag_name: "release" }, 125, repo, releasesUrl));
console.error("Update checker validation passed.");

View file

@ -0,0 +1,142 @@
import { validateZipEntryName, assertRejectsPath, ZipEntryPathTracker, hasSupportedZipMagic } from "./zip-safety.js";
import yauzl from "yauzl";
validateZipEntryName("pet-package/");
validateZipEntryName("pet-package/pet.json");
validateZipEntryName("pet-package/spritesheet.webp");
validateZipEntryName("pet.json");
validateZipEntryName("spritesheet.webp");
const validTracker = new ZipEntryPathTracker();
validTracker.accept("pet-package/");
validTracker.accept("pet-package/pet.json");
validTracker.accept("pet-package/spritesheet.webp");
const validFlatTracker = new ZipEntryPathTracker();
validFlatTracker.accept("pet.json");
validFlatTracker.accept("spritesheet.webp");
for (const unsafe of [
"../pet.json",
"pet-package/../pet.json",
"./pet.json",
"/tmp/pet.json",
"C:/tmp/pet.json",
"pet-package\\pet.json",
"//server/share/pet.json",
"pet-package/pet.json\0.png",
"pet-package/extra.js",
"pet-package/nested/pet.json",
"pet-package//pet.json",
]) {
assertRejectsPath(unsafe);
}
assertRejectsEntrySet(["pet-package/pet.json", "pet-package/pet.json"]);
assertRejectsEntrySet(["pet-package/pet.json", "pet-package/PET.json"]);
assertRejectsEntrySet(["pet-package/pet.json", "other-pet/spritesheet.webp"]);
assertRejectsEntrySet(["pet.json", "pet-package/spritesheet.webp"]);
assertRejectsEntrySet(["pet.json", "pet-package/"]);
assertRejectsEntrySet(["spritesheet.webp", "pet-package/"]);
assertRejectsEntrySet(["pet.json", "pet.json"]);
assertRejectsEntrySet(["pet.json", "PET.json"]);
if (!hasSupportedZipMagic(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) {
throw new Error("Valid local file zip signature was rejected.");
}
if (hasSupportedZipMagic(Buffer.from("not a zip", "utf8"))) {
throw new Error("Non-zip content was accepted as a zip.");
}
await assertYauzlStrictFileNamesRejects("pet-package\\pet.json");
console.log("Zip safety validation passed.");
function assertRejectsEntrySet(entries: readonly string[]): void {
const tracker = new ZipEntryPathTracker();
try {
for (const entry of entries) {
tracker.accept(entry);
}
} catch {
return;
}
throw new Error(`Unsafe zip entry set was accepted: ${entries.join(", ")}`);
}
async function assertYauzlStrictFileNamesRejects(fileName: string): Promise<void> {
const buffer = createSingleEmptyFileZip(fileName);
await new Promise<void>((resolvePromise, rejectPromise) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zipFile) => {
if (error) {
resolvePromise();
return;
}
if (!zipFile) {
rejectPromise(new Error("yauzl did not return a zip file."));
return;
}
zipFile.on("error", () => resolvePromise());
zipFile.on("entry", () => rejectPromise(new Error("yauzl strictFileNames accepted an unsafe file name.")));
zipFile.on("end", () => rejectPromise(new Error("yauzl strictFileNames ended without rejecting unsafe file name.")));
zipFile.readEntry();
});
});
}
function createSingleEmptyFileZip(fileName: string): Buffer {
const fileNameBytes = Buffer.from(fileName, "utf8");
const local = Buffer.alloc(30 + fileNameBytes.length);
let offset = 0;
local.writeUInt32LE(0x04034b50, offset); offset += 4;
local.writeUInt16LE(20, offset); offset += 2;
local.writeUInt16LE(0, offset); offset += 2;
local.writeUInt16LE(0, offset); offset += 2;
local.writeUInt16LE(0, offset); offset += 2;
local.writeUInt16LE(0, offset); offset += 2;
local.writeUInt32LE(0, offset); offset += 4;
local.writeUInt32LE(0, offset); offset += 4;
local.writeUInt32LE(0, offset); offset += 4;
local.writeUInt16LE(fileNameBytes.length, offset); offset += 2;
local.writeUInt16LE(0, offset); offset += 2;
fileNameBytes.copy(local, offset);
const central = Buffer.alloc(46 + fileNameBytes.length);
offset = 0;
central.writeUInt32LE(0x02014b50, offset); offset += 4;
central.writeUInt16LE(20, offset); offset += 2;
central.writeUInt16LE(20, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt32LE(0, offset); offset += 4;
central.writeUInt32LE(0, offset); offset += 4;
central.writeUInt32LE(0, offset); offset += 4;
central.writeUInt16LE(fileNameBytes.length, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt16LE(0, offset); offset += 2;
central.writeUInt32LE(0, offset); offset += 4;
central.writeUInt32LE(0, offset); offset += 4;
fileNameBytes.copy(central, offset);
const end = Buffer.alloc(22);
offset = 0;
end.writeUInt32LE(0x06054b50, offset); offset += 4;
end.writeUInt16LE(0, offset); offset += 2;
end.writeUInt16LE(0, offset); offset += 2;
end.writeUInt16LE(1, offset); offset += 2;
end.writeUInt16LE(1, offset); offset += 2;
end.writeUInt32LE(central.length, offset); offset += 4;
end.writeUInt32LE(local.length, offset); offset += 4;
end.writeUInt16LE(0, offset);
return Buffer.concat([local, central, end]);
}

View file

@ -0,0 +1,210 @@
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
export interface ClaudeOpenPetsMemoryResult {
readonly changed: boolean;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export interface ClaudeOpenPetsMemoryStatus {
readonly status: "installed" | "not_installed" | "error";
readonly message: string;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export const openPetsClaudeImportLine = "@~/.claude/openpets.md";
const openPetsImportStart = "<!-- OPENPETS:IMPORT:START -->";
const openPetsImportEnd = "<!-- OPENPETS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- OPENPETS:START -->";
const openPetsMemoryEnd = "<!-- OPENPETS:END -->";
const maxClaudeMemoryBytes = 1024 * 1024;
export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
mkdirSync(paths.claudeDir, { recursive: true, mode: 0o700 });
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
const nextOpenPetsMemory = upsertOpenPetsMemoryBlock(currentOpenPetsMemory, createOpenPetsMemoryBlock());
const openPetsChanged = currentOpenPetsMemory !== nextOpenPetsMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
const currentClaudeMd = readTextFile(paths.claudeMdPath);
const nextClaudeMd = ensureManagedImport(currentClaudeMd);
const claudeMdChanged = currentClaudeMd !== nextClaudeMd;
if (claudeMdChanged) writePrivateTextFile(paths.claudeMdPath, nextClaudeMd);
return { changed: openPetsChanged || claudeMdChanged, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
let changed = false;
const currentClaudeMd = readTextFile(paths.claudeMdPath);
const hasUserOwnedImport = hasImportLineOutsideManagedBlock(currentClaudeMd);
const nextClaudeMd = removeManagedImport(currentClaudeMd);
if (currentClaudeMd !== nextClaudeMd) {
writePrivateTextFile(paths.claudeMdPath, nextClaudeMd);
changed = true;
}
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
if (currentOpenPetsMemory) {
const nextOpenPetsMemory = removeOpenPetsMemoryBlock(currentOpenPetsMemory);
if (nextOpenPetsMemory.trim().length === 0) {
if (hasUserOwnedImport) {
writePrivateTextFile(paths.openPetsMemoryPath, "");
} else {
rmSync(paths.openPetsMemoryPath, { force: true });
}
changed = true;
} else if (nextOpenPetsMemory !== currentOpenPetsMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
changed = true;
}
}
return { changed, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function doctorClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryStatus {
const paths = getClaudeMemoryPaths(homeDir);
try {
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const claudeMd = readTextFile(paths.claudeMdPath);
const openPetsMemory = readTextFile(paths.openPetsMemoryPath);
const hasImport = hasManagedImport(claudeMd) || hasImportLineOutsideManagedBlock(claudeMd);
const hasInstructions = createOpenPetsBlockPattern().test(openPetsMemory) || /openpets_say|OpenPets MCP/i.test(openPetsMemory);
if (hasImport && hasInstructions) {
return { status: "installed", message: "Claude will load OpenPets instructions from ~/.claude/openpets.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasImport) {
return { status: "not_installed", message: "Claude imports OpenPets instructions, but the OpenPets memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasInstructions) {
return { status: "not_installed", message: "OpenPets instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
return { status: "not_installed", message: "Claude OpenPets instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "Claude OpenPets instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
}
export function getClaudeMemoryPaths(homeDir: string): { readonly claudeDir: string; readonly claudeMdPath: string; readonly openPetsMemoryPath: string } {
const claudeDir = join(homeDir, ".claude");
return {
claudeDir,
claudeMdPath: join(claudeDir, "CLAUDE.md"),
openPetsMemoryPath: join(claudeDir, "openpets.md"),
};
}
export function createOpenPetsMemoryBlock(): string {
return `${openPetsMemoryStart}\n## OpenPets\n\nOpenPets MCP tools may be available.\n\nUse OpenPets as a short visible status channel for meaningful coding progress:\n- Use \`openpets_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`openpets_react\` for small visual or emotional feedback.\n- Use \`openpets_status\` only when checking availability or the targeted pet.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`;
}
export function ensureImportLine(source: string, importLine: string): string {
const lines = source.split(/\r?\n/);
const filtered = lines.filter((line) => line.trim() !== importLine);
const base = filtered.join("\n").replace(/\s*$/u, "");
return base ? `${base}\n\n${importLine}\n` : `${importLine}\n`;
}
export function ensureManagedImport(source: string): string {
const withoutManagedImports = removeManagedImport(source).replace(/\s*$/u, "");
if (withoutManagedImports.split(/\r?\n/).some((line) => line.trim() === openPetsClaudeImportLine)) {
return withoutManagedImports ? `${withoutManagedImports}\n` : "";
}
const block = `${openPetsImportStart}\n${openPetsClaudeImportLine}\n${openPetsImportEnd}`;
return withoutManagedImports ? `${withoutManagedImports}\n\n${block}\n` : `${block}\n`;
}
export function removeManagedImport(source: string): string {
return source.replace(createManagedImportPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : ""));
}
export function removeImportLine(source: string, importLine: string): string {
return source
.split(/\r?\n/)
.filter((line) => line.trim() !== importLine)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : ""));
}
export function upsertOpenPetsMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
return withoutBlocks ? `${withoutBlocks}\n\n${block}` : block;
}
export function removeOpenPetsMemoryBlock(source: string): string {
const withoutBlock = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
return withoutBlock ? `${withoutBlock}\n` : "";
}
function createOpenPetsBlockPattern(): RegExp {
return new RegExp(`${escapeRegExp(openPetsMemoryStart)}[\\s\\S]*?${escapeRegExp(openPetsMemoryEnd)}\\n?`, "g");
}
function createManagedImportPattern(): RegExp {
return new RegExp(`${escapeRegExp(openPetsImportStart)}[\\s\\S]*?${escapeRegExp(openPetsImportEnd)}\\n?`, "g");
}
function hasManagedImport(source: string): boolean {
return createManagedImportPattern().test(source);
}
function hasImportLineOutsideManagedBlock(source: string): boolean {
return removeManagedImport(source).split(/\r?\n/).some((line) => line.trim() === openPetsClaudeImportLine);
}
function assertSafeClaudeMemoryPaths(claudeDir: string, claudeMdPath: string, openPetsMemoryPath: string): void {
if (existsSync(claudeDir)) {
const stat = lstatSync(claudeDir);
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Claude memory directory is not a safe directory.");
}
for (const path of [claudeMdPath, openPetsMemoryPath]) {
if (!existsSync(path)) continue;
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
}
}
function readTextFile(path: string): string {
if (!existsSync(path)) return "";
return readFileSync(path, "utf8");
}
function writePrivateTextFile(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
assertSafeWriteTarget(path);
const tempPath = join(dirname(path), `.${process.pid}.${randomUUID()}.tmp`);
const fd = openSync(tempPath, "wx", 0o600);
try {
writeFileSync(fd, content, { encoding: "utf8" });
} finally {
closeSync(fd);
}
assertSafeWriteTarget(path);
renameSync(tempPath, path);
try { chmodSync(path, 0o600); } catch { /* best effort */ }
}
function assertSafeWriteTarget(path: string): void {
if (!existsSync(path)) return;
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -0,0 +1,40 @@
export const maxCodexPetJsonBytes = 128 * 1024;
export const maxCodexSpritesheetBytes = 100 * 1024 * 1024;
export const maxCodexPreviewBytes = 8 * 1024 * 1024;
export const maxCodexTotalPreviewBytes = 24 * 1024 * 1024;
export const maxCodexPets = 100;
export interface CodexPetMetadata {
readonly id: string;
readonly displayName: string;
readonly description: string;
readonly spritesheetPath: "spritesheet.webp";
}
export function canInlineCodexPreview(size: number): boolean {
return Number.isSafeInteger(size) && size > 0 && size <= maxCodexPreviewBytes;
}
export function validateCodexPetMetadata(value: unknown, folderName: string): CodexPetMetadata {
if (!isSafeCodexPetId(folderName)) throw new Error("Codex pet folder name is invalid.");
if (!isRecord(value)) throw new Error("pet.json must be an object.");
if (value.id !== folderName || typeof value.id !== "string") throw new Error("Codex pet id must match its folder name.");
if (!isSafeCodexPetId(value.id)) throw new Error("Codex pet id is invalid.");
if (typeof value.displayName !== "string" || value.displayName.trim().length === 0 || value.displayName.length > 80) throw new Error("Codex pet displayName is invalid.");
if (typeof value.description !== "string" || value.description.trim().length === 0 || value.description.length > 500) throw new Error("Codex pet description is invalid.");
if (value.spritesheetPath !== "spritesheet.webp") throw new Error("Codex pet spritesheetPath must be spritesheet.webp.");
return {
id: value.id,
displayName: value.displayName.trim(),
description: value.description.trim(),
spritesheetPath: "spritesheet.webp",
};
}
function isSafeCodexPetId(value: string): boolean {
return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value) && value !== "builtin";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -0,0 +1,193 @@
import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { getAppStateSnapshot, installPetState, type OpenPetsStateV1 } from "./app-state.js";
import { canInlineCodexPreview, maxCodexPetJsonBytes, maxCodexPets, maxCodexPreviewBytes, maxCodexSpritesheetBytes, maxCodexTotalPreviewBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-pets-core.js";
import { withPetOperation } from "./pet-installation.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js";
const codexPetsRoot = join(homedir(), ".codex", "pets");
export interface CodexPetUiState {
readonly source: "codex";
readonly pets: readonly CodexPetUiItem[];
readonly error?: string;
}
export interface CodexPetUiItem {
readonly id: string;
readonly displayName: string;
readonly description: string;
readonly preview: string;
}
export async function getCodexPetsUiState(): Promise<CodexPetUiState> {
try {
const root = await validateCodexRoot();
const entries = (await readdir(codexPetsRoot, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
const pets: CodexPetUiItem[] = [];
let remainingPreviewBytes = maxCodexTotalPreviewBytes;
let attemptedDirectories = 0;
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
attemptedDirectories += 1;
if (attemptedDirectories > maxCodexPets) break;
const pet = await tryReadCodexPet(root, join(root, entry.name), entry.name, remainingPreviewBytes);
if (pet) {
remainingPreviewBytes -= pet.previewBytes;
pets.push(pet.item);
}
}
pets.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", pets };
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", pets: [] };
return { source: "codex", pets: [], error: error instanceof Error ? error.message : "Codex pets unavailable." };
}
}
export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
}
const root = await validateCodexRoot();
const sourceDir = resolve(root, petId);
await assertCodexPetDirectory(root, sourceDir);
const metadata = await readCodexPetMetadata(root, sourceDir, petId);
const spritesheetPath = join(sourceDir, metadata.spritesheetPath);
const spritesheet = await readRegularFile(spritesheetPath, maxCodexSpritesheetBytes, "spritesheet.webp");
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const finalDir = getInstalledPetDir(petId);
const tempDir = await mkdtemp(join(petsRoot, `.codex-import-${petId}-`));
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
return installPetState({
id: metadata.id,
displayName: metadata.displayName,
description: metadata.description,
source: { kind: "codex", path: sourceDir },
});
} catch (error) {
await rm(finalDir, { recursive: true, force: true });
throw error;
}
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
async function tryReadCodexPet(root: string, dir: string, folderName: string, remainingPreviewBytes: number): Promise<{ readonly item: CodexPetUiItem; readonly previewBytes: number } | null> {
try {
const metadata = await readCodexPetMetadata(root, dir, folderName);
const spritesheetPath = join(dir, metadata.spritesheetPath);
await validateSpritesheet(spritesheetPath);
const preview = await createSpritesheetDataUrl(spritesheetPath, remainingPreviewBytes);
return {
item: {
id: metadata.id,
displayName: metadata.displayName,
description: metadata.description,
preview: preview.dataUrl,
},
previewBytes: preview.bytes,
};
} catch (error) {
console.error(`Skipping invalid Codex pet at ${dir}.`, error);
return null;
}
}
async function readCodexPetMetadata(root: string, dir: string, folderName: string): Promise<CodexPetMetadata> {
await assertCodexPetDirectory(root, dir);
assertSafePetId(folderName);
const petJson = join(dir, "pet.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const metadata = validateCodexPetMetadata(parsed, folderName);
assertSafePetId(metadata.id);
return metadata;
}
async function validateSpritesheet(path: string): Promise<void> {
const spritesheet = await lstat(path);
if (spritesheet.isSymbolicLink()) throw new Error("spritesheet.webp cannot be a symlink.");
if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file.");
if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty.");
if (spritesheet.size > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
}
async function createSpritesheetDataUrl(path: string, remainingPreviewBytes: number): Promise<{ readonly dataUrl: string; readonly bytes: number }> {
const spritesheet = await lstat(path);
if (spritesheet.isSymbolicLink() || !spritesheet.isFile() || !canInlineCodexPreview(spritesheet.size) || spritesheet.size > remainingPreviewBytes) {
return { dataUrl: "", bytes: 0 };
}
const buffer = await readRegularFile(path, Math.min(maxCodexPreviewBytes, remainingPreviewBytes), "spritesheet.webp preview");
return { dataUrl: `data:image/webp;base64,${buffer.toString("base64")}`, bytes: buffer.byteLength };
}
async function validateCodexRoot(): Promise<string> {
const root = resolve(codexPetsRoot);
const rootStats = await lstat(root);
if (rootStats.isSymbolicLink()) throw new Error("Codex pets root cannot be a symlink.");
if (!rootStats.isDirectory()) throw new Error("Codex pets path is not a directory.");
const realRoot = await realpath(root);
if (realRoot !== root) throw new Error("Codex pets root path is not canonical.");
return root;
}
async function assertCodexPetDirectory(root: string, target: string): Promise<void> {
const resolvedTarget = resolve(target);
if (resolvedTarget === root || !resolvedTarget.startsWith(`${root}${sep}`) || basename(resolvedTarget).startsWith(".")) {
throw new Error("Resolved path escapes Codex pets directory.");
}
const dirStats = await lstat(resolvedTarget);
if (dirStats.isSymbolicLink()) throw new Error("Codex pet directory cannot be a symlink.");
if (!dirStats.isDirectory()) throw new Error("Codex pet path must be a directory.");
const realTarget = await realpath(resolvedTarget);
if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex pet directory escapes Codex pets root.");
}
async function readRegularFile(path: string, maxBytes: number, label: string): Promise<Buffer> {
const stats = await lstat(path);
if (stats.isSymbolicLink()) throw new Error(`${label} cannot be a symlink.`);
if (!stats.isFile()) throw new Error(`${label} must be a file.`);
if (stats.size <= 0 || stats.size > maxBytes) throw new Error(`${label} size is invalid.`);
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const openedStats = await file.stat();
if (!openedStats.isFile()) throw new Error(`${label} must be a file.`);
if (openedStats.size !== stats.size || openedStats.size <= 0 || openedStats.size > maxBytes) throw new Error(`${label} size is invalid.`);
return await file.readFile();
} finally {
await file.close();
}
}
async function validateInstalledRegularFile(path: string): Promise<void> {
const stats = await lstat(path);
if (stats.isSymbolicLink()) throw new Error("Imported pet file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported pet file must be a regular file.");
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}

View file

@ -0,0 +1,184 @@
import { BrowserWindow, screen } from "electron";
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
let defaultPetWindow: BrowserWindow | null = null;
let paused = false;
let transientDisplay: PetTransientDisplay | null = null;
let transientDisplayTimeout: NodeJS.Timeout | null = null;
let transientAnimationTimeout: NodeJS.Timeout | null = null;
export function showDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: true });
const window = getOrCreateDefaultPetWindow();
if (window.isMinimized()) {
window.restore();
}
window.showInactive();
}
export function hideDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: false });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
defaultPetWindow.hide();
}
export function isDefaultPetVisible(): boolean {
return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible());
}
export function setDefaultPetPaused(nextPaused: boolean): void {
paused = nextPaused;
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
}
export function getDefaultPetPaused(): boolean {
return paused;
}
export function refreshDefaultPetContent(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
}
export function applyExternalPetReaction(reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
setTransientDisplay({ reaction });
showDefaultPetForExternalEvent();
return { shown: isDefaultPetVisible() };
}
export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
setTransientDisplay({ message, reaction });
showDefaultPetForExternalEvent();
return { shown: isDefaultPetVisible() };
}
export function destroyDefaultPet(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
defaultPetWindow = null;
return;
}
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
const window = defaultPetWindow;
defaultPetWindow = null;
window.destroy();
}
export function installDefaultPetDisplayHandlers(): void {
screen.on("display-added", reclampDefaultPetWindow);
screen.on("display-removed", reclampDefaultPetWindow);
screen.on("display-metrics-changed", reclampDefaultPetWindow);
}
function getOrCreateDefaultPetWindow(): BrowserWindow {
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
return defaultPetWindow;
}
const position = getSafeDefaultPetPosition(getDefaultPetPosition());
defaultPetWindow = createDefaultPetWindow({
position,
paused,
display: transientDisplay,
onPositionChanged: setDefaultPetPosition,
onHideRequested: hideDefaultPet,
});
defaultPetWindow.on("closed", () => {
defaultPetWindow = null;
});
return defaultPetWindow;
}
function setTransientDisplay(display: PetTransientDisplay): void {
transientDisplay = mergePetTransientDisplay(transientDisplay, display);
if (transientDisplayTimeout) {
clearTimeout(transientDisplayTimeout);
}
if (transientAnimationTimeout) {
clearTimeout(transientAnimationTimeout);
transientAnimationTimeout = null;
}
const animationMs = getTransientReactionAnimationMs(transientDisplay);
if (animationMs !== null && animationMs < transientDisplayMs) {
transientAnimationTimeout = setTimeout(() => {
if (!transientDisplay) return;
transientDisplay = clearTransientReaction(transientDisplay);
transientAnimationTimeout = null;
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) setPetReactionState(defaultPetWindow, "idle");
}, animationMs);
}
transientDisplayTimeout = setTimeout(() => {
transientDisplay = null;
transientDisplayTimeout = null;
if (transientAnimationTimeout) {
clearTimeout(transientAnimationTimeout);
transientAnimationTimeout = null;
}
refreshDefaultPetContent();
}, transientDisplayMs);
refreshDefaultPetContent();
}
function showDefaultPetForExternalEvent(): void {
const state = getAppStateSnapshot();
if (isDefaultPetVisible() || state.preferences.openDefaultPetOnLaunch) {
showDefaultPet();
}
}
function reclampDefaultPetWindow(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
const safePosition = readWindowPosition(defaultPetWindow);
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
setDefaultPetPosition(safePosition);
}
export function shouldOpenDefaultPetOnLaunch(): boolean {
return getAppStateSnapshot().preferences.openDefaultPetOnLaunch;
}
export function resetDefaultPetToInitialPosition(): void {
const safePosition = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
resetDefaultPetPosition(safePosition);
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
}
}

View file

@ -0,0 +1,44 @@
import { screen } from "electron";
export interface Point {
readonly x: number;
readonly y: number;
}
export interface WindowSize {
readonly width: number;
readonly height: number;
}
export const defaultPetWindowSize: WindowSize = {
width: 220,
height: 320,
};
export const defaultPetWindowMargin = 24;
export function getDefaultPetInitialPosition(size: WindowSize = defaultPetWindowSize): Point {
const { workArea } = screen.getPrimaryDisplay();
return {
x: Math.round(workArea.x + workArea.width - size.width - defaultPetWindowMargin),
y: Math.round(workArea.y + workArea.height - size.height - defaultPetWindowMargin),
};
}
export function clampToPrimaryWorkArea(position: Point, size: WindowSize = defaultPetWindowSize): Point {
const { workArea } = screen.getPrimaryDisplay();
const minX = workArea.x;
const minY = workArea.y;
const maxX = workArea.x + Math.max(0, workArea.width - size.width);
const maxY = workArea.y + Math.max(0, workArea.height - size.height);
return {
x: clamp(Math.round(position.x), minX, maxX),
y: clamp(Math.round(position.y), minY, maxY),
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View file

@ -0,0 +1,153 @@
import { randomUUID } from "node:crypto";
export type LeaseTargetKind = "default" | "explicit";
export type LeaseFallbackReason = "invalid_pet_id" | "pet_not_installed" | "pet_broken" | "default_broken_fallback_builtin";
export interface PetLease {
readonly leaseId: string;
readonly requestedPetId?: string;
readonly targetKind: LeaseTargetKind;
readonly actualPetId: string;
readonly fallbackReason?: LeaseFallbackReason;
readonly acquiredAt: number;
readonly lastHeartbeatAt: number;
readonly expiresAt: number;
}
export interface LeaseSnapshot {
readonly leaseId: string;
readonly requestedPetId?: string;
readonly targetKind: LeaseTargetKind;
readonly actualTargetPetId: string;
readonly actualTargetPetName: string;
readonly usingDefaultPet: boolean;
readonly fallbackReason?: LeaseFallbackReason;
readonly expiresAt: number;
readonly leaseActive: boolean;
}
export interface LeaseManagerOptions {
readonly ttlMs?: number;
readonly now?: () => number;
readonly resolveTarget?: (requestedPetId: string | undefined) => { readonly targetKind: LeaseTargetKind; readonly actualPetId: string; readonly fallbackReason?: LeaseFallbackReason };
readonly getDefaultPetId?: () => string;
readonly getPetDisplayName?: (petId: string, targetKind: LeaseTargetKind) => string;
readonly onFirstExplicitLease?: (petId: string) => void;
readonly onLastExplicitLease?: (petId: string) => void;
}
const safePetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export class LeaseManager {
readonly #leases = new Map<string, PetLease>();
readonly #ttlMs: number;
readonly #now: () => number;
readonly #resolveTarget: (requestedPetId: string | undefined) => { readonly targetKind: LeaseTargetKind; readonly actualPetId: string; readonly fallbackReason?: LeaseFallbackReason };
readonly #getDefaultPetId: () => string;
readonly #getPetDisplayName: (petId: string, targetKind: LeaseTargetKind) => string;
readonly #onFirstExplicitLease: (petId: string) => void;
readonly #onLastExplicitLease: (petId: string) => void;
constructor(options: LeaseManagerOptions = {}) {
this.#ttlMs = options.ttlMs ?? 15_000;
this.#now = options.now ?? Date.now;
this.#resolveTarget = options.resolveTarget ?? (() => { throw new Error("Lease target resolver is not configured."); });
this.#getDefaultPetId = options.getDefaultPetId ?? (() => { throw new Error("Default pet resolver is not configured."); });
this.#getPetDisplayName = options.getPetDisplayName ?? ((petId) => petId);
this.#onFirstExplicitLease = options.onFirstExplicitLease ?? (() => {});
this.#onLastExplicitLease = options.onLastExplicitLease ?? (() => {});
}
acquire(requestedPetId?: string): LeaseSnapshot {
const now = this.#now();
const target = this.#resolveTarget(requestedPetId);
const lease: PetLease = {
leaseId: randomUUID(),
requestedPetId,
targetKind: target.targetKind,
actualPetId: target.actualPetId,
fallbackReason: target.fallbackReason,
acquiredAt: now,
lastHeartbeatAt: now,
expiresAt: now + this.#ttlMs,
};
const hadExplicitLease = lease.targetKind === "explicit" && this.countExplicitLeases(lease.actualPetId) > 0;
this.#leases.set(lease.leaseId, lease);
if (lease.targetKind === "explicit" && !hadExplicitLease) this.#onFirstExplicitLease(lease.actualPetId);
return this.snapshot(lease);
}
heartbeat(leaseId: string): { readonly leaseId: string; readonly expiresAt: number } {
const lease = this.#leases.get(leaseId);
if (!lease) throw new Error("unknown_lease");
const now = this.#now();
if (lease.expiresAt <= now) {
this.release(leaseId);
throw new Error("unknown_lease");
}
const next: PetLease = { ...lease, lastHeartbeatAt: now, expiresAt: now + this.#ttlMs };
this.#leases.set(leaseId, next);
return { leaseId, expiresAt: next.expiresAt };
}
release(leaseId: string): { readonly released: boolean } {
const lease = this.#leases.get(leaseId);
if (!lease) return { released: false };
this.#leases.delete(leaseId);
if (lease.targetKind === "explicit" && this.countExplicitLeases(lease.actualPetId) === 0) {
this.#onLastExplicitLease(lease.actualPetId);
}
return { released: true };
}
get(leaseId: string): LeaseSnapshot | null {
const lease = this.#leases.get(leaseId);
if (lease && lease.expiresAt <= this.#now()) {
this.release(leaseId);
return null;
}
return lease ? this.snapshot(lease) : null;
}
cleanupExpired(): readonly LeaseSnapshot[] {
const now = this.#now();
const expired: LeaseSnapshot[] = [];
for (const lease of [...this.#leases.values()]) {
if (lease.expiresAt <= now) {
expired.push(this.snapshot(lease));
this.release(lease.leaseId);
}
}
return expired;
}
countExplicitLeases(petId: string): number {
let count = 0;
for (const lease of this.#leases.values()) {
if (lease.targetKind === "explicit" && lease.actualPetId === petId) count += 1;
}
return count;
}
snapshot(lease: PetLease): LeaseSnapshot {
const defaultPetId = this.#getDefaultPetId();
const actualPetId = lease.targetKind === "default" ? defaultPetId : lease.actualPetId;
const targetKind = lease.targetKind;
return {
leaseId: lease.leaseId,
requestedPetId: lease.requestedPetId,
targetKind,
actualTargetPetId: actualPetId,
actualTargetPetName: this.#getPetDisplayName(actualPetId, targetKind),
usingDefaultPet: targetKind === "default",
fallbackReason: lease.fallbackReason,
expiresAt: lease.expiresAt,
leaseActive: true,
};
}
}
export function createStaleLeaseStatus(leaseId: string): { readonly ok: false; readonly appRunning: true; readonly leaseId: string; readonly leaseActive: false; readonly staleReason: "unknown_lease" } {
return { ok: false, appRunning: true, leaseId, leaseActive: false, staleReason: "unknown_lease" };
}

View file

@ -0,0 +1,37 @@
import { app } from "electron";
import { closeAllAgentPets } from "./agent-pet-controller.js";
import { destroyDefaultPet } from "./default-pet-controller.js";
import { stopLocalIpcServer } from "./local-ipc.js";
import { focusOpenTaskWindows } from "./windows.js";
let intentionalQuit = false;
export function installAppLifecycle(): void {
app.on("second-instance", () => {
console.log("Second OpenPets launch requested; keeping existing instance.");
focusOpenTaskWindows();
});
app.on("window-all-closed", () => {
if (!intentionalQuit) {
console.log("All OpenPets task windows closed; keeping tray app running.");
}
});
app.on("activate", () => {
console.log("OpenPets activate event received; not opening a dashboard window.");
});
app.on("before-quit", () => {
intentionalQuit = true;
stopLocalIpcServer();
closeAllAgentPets();
destroyDefaultPet();
});
}
export function quitOpenPets(): void {
intentionalQuit = true;
app.quit();
}

View file

@ -0,0 +1,150 @@
import { lstatSync, mkdirSync, writeFileSync, renameSync, rmSync, chmodSync, readFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { homedir, userInfo } from "node:os";
import { dirname, join } from "node:path";
import { app } from "electron";
import { openPetsIpcProtocol, openPetsIpcVersion } from "./local-ipc-protocol.js";
export interface OpenPetsDiscoveryFile {
readonly protocolVersion: 1;
readonly protocol: "openpets-ipc";
readonly endpoint: string;
readonly token: string;
readonly appVersion: string;
readonly pid: number;
readonly platform: NodeJS.Platform;
}
export function getDiscoveryFilePath(): string {
if (process.env.OPENPETS_DISCOVERY_FILE) {
return process.env.OPENPETS_DISCOVERY_FILE;
}
if (process.platform === "darwin") {
return join(homedir(), "Library", "Application Support", "OpenPets", "runtime", "ipc.json");
}
if (process.platform === "win32") {
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "OpenPets", "runtime", "ipc.json");
}
const xdg = getSecureXdgRuntimeDir();
if (xdg) {
return join(xdg, "openpets", "ipc.json");
}
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "OpenPets", "runtime", "ipc.json");
}
export function createIpcEndpoint(): string {
if (process.platform === "win32") {
return `\\\\.\\pipe\\openpets-${randomEndpointPart()}-${process.pid}`;
}
const runtimeDir = getSocketRuntimeDir();
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
ensurePrivateRuntimeDir(runtimeDir);
return join(runtimeDir, `openpets-${process.pid}.sock`);
}
export function writeDiscoveryFile(endpoint: string, token: string): OpenPetsDiscoveryFile {
const discovery: OpenPetsDiscoveryFile = {
protocolVersion: openPetsIpcVersion,
protocol: openPetsIpcProtocol,
endpoint,
token,
appVersion: app.getVersion(),
pid: process.pid,
platform: process.platform,
};
const path = getDiscoveryFilePath();
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try { chmodSync(dirname(path), 0o700); } catch { /* best effort */ }
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(discovery, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
try { chmodSync(tempPath, 0o600); } catch { /* best effort */ }
renameSync(tempPath, path);
try { chmodSync(path, 0o600); } catch { /* best effort */ }
return discovery;
}
export function removeDiscoveryFile(discovery: OpenPetsDiscoveryFile | null): void {
if (!discovery) return;
const path = getDiscoveryFilePath();
try {
const current = JSON.parse(readFileSync(path, "utf8")) as Partial<OpenPetsDiscoveryFile>;
if (current.pid !== discovery.pid || current.token !== discovery.token || current.endpoint !== discovery.endpoint) {
return;
}
rmSync(path, { force: true });
} catch {
// best-effort cleanup only
}
}
export function cleanupUnixSocket(endpoint: string): void {
if (process.platform === "win32") return;
try {
rmSync(endpoint, { force: true });
} catch {
// bind will report a real failure if cleanup was required but impossible
}
}
export function protectUnixSocket(endpoint: string): void {
if (process.platform === "win32") return;
try { chmodSync(endpoint, 0o600); } catch { /* best effort */ }
}
function getSocketRuntimeDir(): string {
const xdg = process.platform === "linux" ? getSecureXdgRuntimeDir() : null;
if (xdg) {
return join(xdg, "openpets");
}
return join("/tmp", `openpets-${getUserIdForPath()}`);
}
function getSecureXdgRuntimeDir(): string | null {
const dir = process.env.XDG_RUNTIME_DIR;
if (!dir) return null;
try {
const stat = lstatSync(dir);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return null;
if ((stat.mode & 0o777) !== 0o700) return null;
return dir;
} catch {
return null;
}
}
function ensurePrivateRuntimeDir(dir: string): void {
const stat = lstatSync(dir);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`OpenPets IPC runtime path is not a safe directory: ${dir}`);
}
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
throw new Error(`OpenPets IPC runtime directory is not owned by the current user: ${dir}`);
}
try { chmodSync(dir, 0o700); } catch { /* best effort */ }
const updated = lstatSync(dir);
if ((updated.mode & 0o777) !== 0o700) {
throw new Error(`OpenPets IPC runtime directory is not private: ${dir}`);
}
}
function getUserIdForPath(): string {
if (typeof process.getuid === "function") return String(process.getuid());
try { return userInfo().username.replace(/[^a-zA-Z0-9_-]/g, "_"); } catch { return "user"; }
}
function randomEndpointPart(): string {
return randomBytes(8).toString("hex");
}

View file

@ -0,0 +1,136 @@
import { randomUUID } from "node:crypto";
export const openPetsIpcProtocol = "openpets-ipc";
export const openPetsIpcVersion = 1;
export const maxIpcMessageBytes = 16 * 1024;
export const transientDisplayMs = 4_000;
export const allowedReactions = [
"idle",
"thinking",
"working",
"editing",
"running",
"testing",
"waiting",
"waving",
"success",
"error",
"celebrating",
] as const;
export type OpenPetsReaction = typeof allowedReactions[number];
export type OpenPetsIpcMethod = "hello" | "status" | "pets.list" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "pet.react" | "pet.say";
export interface OpenPetsIpcRequest {
readonly id: string;
readonly version: number;
readonly token: string;
readonly method: OpenPetsIpcMethod;
readonly params?: unknown;
}
export interface OpenPetsIpcResponse {
readonly id: string | null;
readonly ok: boolean;
readonly result?: unknown;
readonly error?: {
readonly code: string;
readonly message: string;
};
}
export function createRequestId(): string {
return randomUUID();
}
export function parseIpcRequest(raw: string, expectedToken: string): OpenPetsIpcRequest {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
throw new IpcProtocolError("invalid_request", "IPC request must be valid JSON.");
}
if (!isRecord(parsed)) throw new IpcProtocolError("invalid_request", "IPC request must be an object.");
if (typeof parsed.id !== "string" || parsed.id.length < 1 || parsed.id.length > 120) throw new IpcProtocolError("invalid_request", "IPC request id is invalid.");
if (parsed.version !== openPetsIpcVersion) throw new IpcProtocolError("invalid_version", "Unsupported IPC protocol version.");
if (parsed.token !== expectedToken) throw new IpcProtocolError("invalid_token", "Invalid IPC token.");
if (parsed.method !== "hello" && parsed.method !== "status" && parsed.method !== "pets.list" && parsed.method !== "lease.acquire" && parsed.method !== "lease.heartbeat" && parsed.method !== "lease.release" && parsed.method !== "pet.react" && parsed.method !== "pet.say") {
throw new IpcProtocolError("unknown_method", "Unknown IPC method.");
}
return {
id: parsed.id,
version: parsed.version,
token: parsed.token,
method: parsed.method,
params: parsed.params,
};
}
export function validateReaction(value: unknown): OpenPetsReaction {
if (typeof value !== "string" || !allowedReactions.includes(value as OpenPetsReaction)) {
throw new IpcProtocolError("invalid_params", "Invalid pet reaction.");
}
return value as OpenPetsReaction;
}
export function validateSayMessage(value: unknown): string {
if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Message must be a string.");
const message = value.trim();
if (message.length < 1) throw new IpcProtocolError("invalid_params", "Message cannot be empty.");
if (message.length > 140) throw new IpcProtocolError("invalid_params", "Message is too long.");
if (/[\r\n]/.test(message)) throw new IpcProtocolError("invalid_params", "Message must be single-line.");
if (/```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b/.test(message)) throw new IpcProtocolError("invalid_params", "Message looks like code.");
if (/https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\/.test(message)) throw new IpcProtocolError("invalid_params", "Message contains a URL or path-like content.");
if (/(api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY)/i.test(message)) throw new IpcProtocolError("invalid_params", "Message looks secret-like.");
return message;
}
export function validateOptionalLeaseId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || value.length < 1 || value.length > 120 || /[\0\r\n]/.test(value)) {
throw new IpcProtocolError("invalid_params", "Invalid lease id.");
}
return value;
}
export function validateRequestedPetId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Requested pet id must be a string.");
const trimmed = value.trim();
if (trimmed.length < 1) return undefined;
if (Buffer.byteLength(trimmed, "utf8") > 128 || /[\x00-\x1F\x7F/\\]/.test(trimmed)) {
throw new IpcProtocolError("invalid_params", "Requested pet id is outside CLI bounds.");
}
return trimmed;
}
export function okResponse(id: string | null, result: unknown): OpenPetsIpcResponse {
return { id, ok: true, result };
}
export function errorResponse(id: string | null, error: unknown): OpenPetsIpcResponse {
if (error instanceof IpcProtocolError) {
return { id, ok: false, error: { code: error.code, message: error.message } };
}
return {
id,
ok: false,
error: {
code: "internal_error",
message: error instanceof Error ? error.message : "IPC request failed.",
},
};
}
export class IpcProtocolError extends Error {
constructor(readonly code: string, message: string) {
super(message);
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -0,0 +1,266 @@
import { randomBytes } from "node:crypto";
import net from "node:net";
import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-pet-controller.js";
import { getAppStateSnapshot } from "./app-state.js";
import { builtInPet } from "./built-in-pet.js";
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-pet-controller.js";
import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js";
import { cleanupUnixSocket, createIpcEndpoint, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type OpenPetsDiscoveryFile } from "./local-ipc-paths.js";
import { errorResponse, IpcProtocolError, isRecord, maxIpcMessageBytes, okResponse, parseIpcRequest, validateOptionalLeaseId, validateReaction, validateRequestedPetId, validateSayMessage, type OpenPetsIpcRequest } from "./local-ipc-protocol.js";
let ipcServer: net.Server | null = null;
let ipcDiscovery: OpenPetsDiscoveryFile | null = null;
let leaseCleanupTimer: NodeJS.Timeout | null = null;
const leaseManager = new LeaseManager({
resolveTarget: resolveLeaseTarget,
getDefaultPetId: () => getCurrentDefaultPet().id,
getPetDisplayName: (petId, targetKind) => targetKind === "default" ? getCurrentDefaultPet().displayName : getPetDisplayName(petId),
onFirstExplicitLease: showAgentPet,
onLastExplicitLease: handleLastExplicitLease,
});
const safePetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export async function startLocalIpcServer(): Promise<void> {
if (ipcServer) return;
const endpoint = createIpcEndpoint();
const token = randomBytes(32).toString("base64url");
cleanupUnixSocket(endpoint);
const server = net.createServer((socket) => handleSocket(socket, token));
server.on("error", (error) => {
console.error("OpenPets local IPC server error.", error);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(endpoint, () => {
server.off("error", reject);
protectUnixSocket(endpoint);
resolve();
});
});
ipcServer = server;
ipcDiscovery = writeDiscoveryFile(endpoint, token);
leaseCleanupTimer = setInterval(() => leaseManager.cleanupExpired(), 5_000);
leaseCleanupTimer.unref?.();
console.log(`OpenPets local IPC listening at ${endpoint}.`);
}
export function stopLocalIpcServer(): void {
const server = ipcServer;
const discovery = ipcDiscovery;
ipcServer = null;
ipcDiscovery = null;
if (leaseCleanupTimer) clearInterval(leaseCleanupTimer);
leaseCleanupTimer = null;
removeDiscoveryFile(discovery);
if (server) {
server.close();
}
if (discovery) {
cleanupUnixSocket(discovery.endpoint);
}
}
function handleSocket(socket: net.Socket, token: string): void {
socket.setEncoding("utf8");
socket.setTimeout(3_000, () => socket.destroy());
let buffer = "";
let handled = false;
socket.on("data", (chunk) => {
if (handled) return;
buffer += chunk;
if (Buffer.byteLength(buffer, "utf8") > maxIpcMessageBytes) {
handled = true;
writeResponse(socket, errorResponse(null, new IpcProtocolError("invalid_request", "IPC request is too large.")));
return;
}
const newline = buffer.indexOf("\n");
if (newline === -1) return;
handled = true;
const raw = buffer.slice(0, newline);
void handleRawRequest(raw, token).then((response) => writeResponse(socket, response));
});
socket.on("error", (error) => {
if (isBenignSocketCloseError(error)) return;
console.error("OpenPets local IPC client socket error.", error);
});
}
async function handleRawRequest(raw: string, token: string) {
let requestId: string | null = null;
try {
const request = parseIpcRequest(raw, token);
requestId = request.id;
return okResponse(request.id, await handleRequest(request));
} catch (error) {
return errorResponse(requestId, error);
}
}
async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
if (request.method === "hello") {
return {
ok: true,
protocol: "openpets-ipc",
protocolVersion: 1,
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
};
}
if (request.method === "status") {
const params = isRecord(request.params) ? request.params : {};
const leaseId = validateOptionalLeaseId(params.leaseId);
if (leaseId) {
const lease = leaseManager.get(leaseId);
if (!lease) return createStaleLeaseStatus(leaseId);
return { ok: true, appRunning: true, ...lease };
}
const state = getAppStateSnapshot();
const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId) ?? builtInPet;
return {
ok: true,
appRunning: true,
protocolVersion: 1,
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
defaultPet: {
id: defaultPet.id,
displayName: defaultPet.displayName,
builtIn: defaultPet.builtIn,
broken: "broken" in defaultPet && defaultPet.broken === true,
},
paused: getDefaultPetPaused(),
defaultPetVisible: isDefaultPetVisible(),
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
speechBubblesEnabled: state.preferences.speechBubblesEnabled,
};
}
if (request.method === "pets.list") {
const state = getAppStateSnapshot();
return {
ok: true,
pets: state.pets.installed.map((pet) => ({
id: pet.id,
displayName: pet.displayName,
builtIn: pet.builtIn,
broken: pet.broken === true,
})),
defaultPetId: state.preferences.defaultPetId,
};
}
if (request.method === "lease.acquire") {
const params = isRecord(request.params) ? request.params : {};
return leaseManager.acquire(validateRequestedPetId(params.requestedPetId));
}
if (request.method === "lease.heartbeat") {
const params = isRecord(request.params) ? request.params : {};
const leaseId = validateRequiredLeaseId(params.leaseId);
try {
return leaseManager.heartbeat(leaseId);
} catch {
throw new IpcProtocolError("unknown_lease", "Unknown or expired lease.");
}
}
if (request.method === "lease.release") {
const params = isRecord(request.params) ? request.params : {};
return leaseManager.release(validateRequiredLeaseId(params.leaseId));
}
if (request.method === "pet.react") {
const params = isRecord(request.params) ? request.params : {};
const reaction = validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId };
const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction);
return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId };
}
const applied = applyExternalPetReaction(reaction);
return { ok: true, reaction, shown: applied.shown, reason: applied.reason };
}
const params = isRecord(request.params) ? request.params : {};
const message = validateSayMessage(params.message);
const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId };
const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction);
return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId };
}
const applied = applyExternalPetSay(message, reaction);
return { ok: true, shown: applied.shown, reason: applied.reason, reaction };
}
function validateRequiredLeaseId(value: unknown): string {
const leaseId = validateOptionalLeaseId(value);
if (!leaseId) throw new IpcProtocolError("invalid_params", "Lease id is required.");
return leaseId;
}
function getLeaseTarget(value: unknown) {
const leaseId = validateOptionalLeaseId(value);
if (!leaseId) return null;
const lease = leaseManager.get(leaseId);
if (!lease) throw new IpcProtocolError("unknown_lease", "Unknown or expired lease.");
return lease;
}
function handleLastExplicitLease(petId: string): void {
clearAgentPetLeaseState(petId);
}
function writeResponse(socket: net.Socket, response: unknown): void {
if (socket.destroyed || !socket.writable) return;
socket.end(`${JSON.stringify(response)}\n`);
}
function isBenignSocketCloseError(error: NodeJS.ErrnoException): boolean {
return error.code === "EPIPE" || error.code === "ECONNRESET" || error.code === "ERR_STREAM_DESTROYED";
}
function resolveLeaseTarget(requestedPetId: string | undefined): { readonly targetKind: "default" | "explicit"; readonly actualPetId: string; readonly fallbackReason?: "invalid_pet_id" | "pet_not_installed" | "pet_broken" | "default_broken_fallback_builtin" } {
const defaultPet = getCurrentDefaultPetWithFallback();
if (!requestedPetId || requestedPetId === builtInPet.id || requestedPetId === defaultPet.id) {
return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: defaultPet.fallbackReason };
}
if (!safePetIdPattern.test(requestedPetId)) {
return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "invalid_pet_id" };
}
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === requestedPetId);
if (!pet) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_not_installed" };
if (pet.broken) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_broken" };
return { targetKind: "explicit", actualPetId: pet.id };
}
function getCurrentDefaultPet(): { readonly id: string; readonly displayName: string } {
const pet = getCurrentDefaultPetWithFallback();
return { id: pet.id, displayName: pet.displayName };
}
function getCurrentDefaultPetWithFallback(): { readonly id: string; readonly displayName: string; readonly fallbackReason?: "default_broken_fallback_builtin" } {
const state = getAppStateSnapshot();
const configuredDefault = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
if (configuredDefault && !configuredDefault.broken) return configuredDefault;
return { ...builtInPet, fallbackReason: "default_broken_fallback_builtin" };
}
function getPetDisplayName(petId: string): string {
return getAppStateSnapshot().pets.installed.find((pet) => pet.id === petId)?.displayName ?? petId;
}

53
apps/desktop/src/main.ts Normal file
View file

@ -0,0 +1,53 @@
import { app } from "electron";
import { initializeAppState, isOnboardingCompleted } from "./app-state.js";
import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js";
import { installAppLifecycle } from "./lifecycle.js";
import { startLocalIpcServer } from "./local-ipc.js";
import { createAppTray, refreshTrayMenu } from "./tray.js";
import { checkForGitHubReleaseUpdate } from "./update-checker.js";
import { installInternalUiHandlers, openTaskWindow } from "./windows.js";
// OpenPets does not store browser passwords, cookies, or encrypted app secrets.
// Keep Chromium/Electron from prompting for macOS Keychain or Linux keyring access
// during startup/profile initialization.
app.commandLine.appendSwitch("use-mock-keychain");
app.commandLine.appendSwitch("password-store", "basic");
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
installAppLifecycle();
app.whenReady().then(async () => {
app.setName("OpenPets");
if (process.platform === "darwin") {
app.dock?.hide();
}
initializeAppState();
installInternalUiHandlers();
createAppTray();
installDefaultPetDisplayHandlers();
await startLocalIpcServer();
if (shouldOpenDefaultPetOnLaunch()) {
showDefaultPet();
}
if (!isOnboardingCompleted()) {
try {
openTaskWindow("onboarding");
} catch (error) {
console.error("Failed to open OpenPets onboarding; continuing with tray app.", error);
}
}
refreshTrayMenu();
void checkForGitHubReleaseUpdate().then(() => refreshTrayMenu());
console.log("OpenPets desktop shell ready.");
}).catch((error: unknown) => {
console.error("Failed to start OpenPets desktop shell.", error);
app.quit();
});
}

View file

@ -0,0 +1,338 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js";
import { getCatalogUiState } from "./catalog.js";
import type { CatalogPetV2 } from "./catalog-validation.js";
import { builtInPet } from "./built-in-pet.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js";
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
const maxZipDownloadBytes = 50 * 1024 * 1024;
const maxExtractedTotalBytes = 200 * 1024 * 1024;
const maxFiles = 500;
const maxIndividualFileBytes = 100 * 1024 * 1024;
const downloadTimeoutMs = 30_000;
const operations = new Set<string>();
export async function installPet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
}
const catalogPet = await getCatalogPet(petId);
const zip = await downloadPetZip(catalogPet.zip);
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const finalDir = getInstalledPetDir(petId);
const tempDir = await mkdtemp(join(petsRoot, `.install-${petId}-`));
try {
assertInsideRoot(petsRoot, tempDir);
await extractPetZip(zip, tempDir);
await validateExtractedPet(tempDir);
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
return installPetState({
id: catalogPet.id,
displayName: catalogPet.displayName,
description: catalogPet.description,
source: {
catalogVersion: 2,
zip: catalogPet.zip,
preview: catalogPet.preview,
},
});
} catch (error) {
await rm(finalDir, { recursive: true, force: true });
throw error;
}
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function removePet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
if (petId === builtInPet.id) {
throw new Error("Built-in pet cannot be removed.");
}
assertSafePetId(petId);
const dir = getInstalledPetDir(petId);
const state = removePetState(petId);
try {
await rm(dir, { recursive: true, force: true });
} catch (error) {
throw new Error(`Pet was removed from OpenPets state, but local files could not be deleted from ${dir}. You may need to remove them manually. ${error instanceof Error ? error.message : ""}`.trim());
}
return state;
});
}
export async function setDefaultInstalledPet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
if (petId !== builtInPet.id) {
assertSafePetId(petId);
}
return setDefaultPet(petId);
});
}
export async function withPetOperation<T>(key: string, callback: () => Promise<T>): Promise<T> {
if (operations.has(key)) {
throw new Error("An operation for this pet is already in progress.");
}
operations.add(key);
try {
return await callback();
} finally {
operations.delete(key);
}
}
async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
const catalog = await getCatalogUiState();
const pet = catalog.pets.find((candidate) => candidate.id === petId);
if (!pet) {
throw new Error(`Pet is not available in the validated catalog: ${petId}`);
}
return pet;
}
async function downloadPetZip(zipUrl: string): Promise<Buffer> {
validateZipUrl(zipUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
try {
const response = await fetch(zipUrl, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateZipUrl(response.url);
if (response.url !== zipUrl) throw new Error("Zip download final URL changed.");
if (!response.ok) throw new Error(`Zip download failed with HTTP ${response.status}.`);
const buffer = await readLimitedResponse(response, maxZipDownloadBytes);
validateZipMagic(buffer);
return buffer;
} finally {
clearTimeout(timeout);
}
}
function validateZipUrl(value: string): void {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("Zip URL must use https.");
if (url.hostname !== "zip.openpets.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/pets/")) throw new Error("Zip URL path is not allowed.");
if (url.username || url.password) throw new Error("Zip URL cannot include credentials.");
if (url.port) throw new Error("Zip URL cannot include a custom port.");
}
async function readLimitedResponse(response: Response, maxBytes: number): Promise<Buffer> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Zip response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw new Error("Zip download is too large.");
chunks.push(value);
}
return Buffer.concat(chunks, total);
}
function validateZipMagic(buffer: Buffer): void {
if (!hasSupportedZipMagic(buffer)) {
throw new Error("Downloaded file has an unsupported zip signature.");
}
}
async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
const zipFile = await openZipFromBuffer(zip);
const pathTracker = new ZipEntryPathTracker();
const seenRequired = new Set<string>();
let fileCount = 0;
let extractedTotal = 0;
try {
await new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false;
const reject = (error: unknown): void => {
if (settled) return;
settled = true;
zipFile.close();
rejectPromise(error instanceof Error ? error : new Error("Zip extraction failed."));
};
zipFile.on("error", reject);
zipFile.on("end", () => {
if (settled) return;
settled = true;
resolvePromise();
});
zipFile.on("entry", (entry) => {
void processEntry(entry).then(() => {
if (!settled) zipFile.readEntry();
}).catch(reject);
});
const processEntry = async (entry: Entry): Promise<void> => {
validateEntryMetadata(entry);
const safePath = pathTracker.accept(entry.fileName);
if (safePath.isDirectory) {
return;
}
if (!safePath.relativeOutputPath) {
throw new Error("Zip file entry is missing an output path.");
}
fileCount += 1;
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large.");
extractedTotal += entry.uncompressedSize;
if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large.");
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
assertOutputPathInside(tempDir, outputPath);
seenRequired.add(safePath.relativeOutputPath);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize);
};
zipFile.readEntry();
});
} finally {
zipFile.close();
}
if (!seenRequired.has("pet.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain pet.json and spritesheet.webp.");
}
}
function openZipFromBuffer(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolvePromise, rejectPromise) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zipFile) => {
if (error) {
rejectPromise(error);
return;
}
if (!zipFile) {
rejectPromise(new Error("Zip file could not be opened."));
return;
}
resolvePromise(zipFile);
});
});
}
function validateEntryMetadata(entry: Entry): void {
if (entry.isEncrypted()) throw new Error("Encrypted zip entries are not supported.");
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
throw new Error("Unsupported zip entry compression method.");
}
if (entry.compressedSize > maxZipDownloadBytes) throw new Error("Zip entry compressed size is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry uncompressed size is too large.");
const unixMode = getUnixMode(entry);
if (unixMode === null) return;
const type = unixMode & 0o170000;
const isKnownFileType = type !== 0;
const isRegularFile = type === 0o100000;
const isDirectory = type === 0o040000;
if (isKnownFileType && !isRegularFile && !isDirectory) {
throw new Error("Zip entry special files are not supported.");
}
}
function getUnixMode(entry: Entry): number | null {
if ((entry.versionMadeBy >> 8) !== 3) {
return null;
}
return (entry.externalFileAttributes >> 16) & 0o177777;
}
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
zipFile.openReadStream(entry, (error, readStream) => {
if (error) {
rejectPromise(error);
return;
}
if (!readStream) {
rejectPromise(new Error("Zip entry stream could not be opened."));
return;
}
let actualBytes = 0;
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
actualBytes += chunk.byteLength;
if (actualBytes > maxIndividualFileBytes) {
callback(new Error("Zip entry exceeded individual size limit."));
return;
}
callback(null, chunk);
},
});
pipeline(readStream, counter, createWriteStream(outputPath, { mode: 0o600 }))
.then(() => {
if (actualBytes !== expectedBytes) {
rejectPromise(new Error("Zip entry extracted size did not match metadata."));
return;
}
resolvePromise();
})
.catch(rejectPromise);
});
});
}
async function validateExtractedPet(tempDir: string): Promise<void> {
const petJsonPath = join(tempDir, "pet.json");
const spritesheetPath = join(tempDir, "spritesheet.webp");
assertOutputPathInside(tempDir, petJsonPath);
assertOutputPathInside(tempDir, spritesheetPath);
JSON.parse(await readFile(petJsonPath, "utf8")) as unknown;
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file.");
if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty.");
if (spritesheet.size > maxIndividualFileBytes) throw new Error("spritesheet.webp is too large.");
}

View file

@ -0,0 +1,30 @@
import { join, resolve, sep } from "node:path";
import { app } from "electron";
export function getPetsRoot(): string {
return join(app.getPath("userData"), "pets");
}
export function getInstalledPetDir(petId: string): string {
assertSafePetId(petId);
const root = getPetsRoot();
const target = resolve(root, petId);
assertInsideRoot(root, target);
return target;
}
export function assertSafePetId(petId: string): void {
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(petId) || petId === "builtin") {
throw new Error(`Invalid installed pet id: ${petId}`);
}
}
export function assertInsideRoot(root: string, target: string): void {
const resolvedRoot = resolve(root);
const resolvedTarget = resolve(target);
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${sep}`)) {
throw new Error("Resolved path escapes OpenPets pets directory.");
}
}

View file

@ -0,0 +1,545 @@
import { app, BrowserWindow, ipcMain, Menu, type IpcMainEvent } from "electron";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js";
import { clampToPrimaryWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { getInstalledPetDir } from "./pet-paths.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { pickReactionMessage } from "./reaction-messages.js";
export interface DefaultPetWindowOptions {
readonly position: Point;
readonly paused: boolean;
readonly display: PetTransientDisplay | null;
readonly onPositionChanged: (position: Point) => void;
readonly onHideRequested: () => void;
}
export interface AgentPetWindowOptions {
readonly petId: string;
readonly displayName: string;
readonly position: Point;
readonly display: PetTransientDisplay | null;
readonly onCloseRequested: () => void;
}
export interface PetTransientDisplay {
readonly reaction?: OpenPetsReaction;
readonly message?: string;
readonly reactionMessage?: string;
}
type PetMotionState = "idle" | "run-left" | "run-right";
type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review";
const motionToSpriteState = {
idle: "idle",
"run-right": "running-right",
"run-left": "running-left",
} as const satisfies Record<PetMotionState, UniversalSpriteState>;
const reactionToSpriteState = {
idle: "idle",
thinking: "review",
working: "running",
editing: "running",
running: "running",
testing: "waiting",
waiting: "waiting",
waving: "waving",
success: "jumping",
error: "failed",
celebrating: "jumping",
} as const satisfies Record<OpenPetsReaction, UniversalSpriteState>;
const defaultPetSprite = {
fileName: "default-pet-spritesheet.webp",
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
states: {
idle: { row: 0, frames: 6, durationMs: 5500, iterations: "infinite" },
"running-right": { row: 1, frames: 8, durationMs: 1060 },
"running-left": { row: 2, frames: 8, durationMs: 1060 },
waving: { row: 3, frames: 4, durationMs: 700, iterations: 2 },
jumping: { row: 4, frames: 5, durationMs: 840, iterations: 2 },
failed: { row: 5, frames: 8, durationMs: 1220, iterations: 2 },
waiting: { row: 6, frames: 6, durationMs: 1010 },
running: { row: 7, frames: 6, durationMs: 820 },
review: { row: 8, frames: 6, durationMs: 1030 },
} satisfies Record<UniversalSpriteState, { readonly row: number; readonly frames: number; readonly durationMs: number; readonly iterations?: number | "infinite" }>,
} as const;
export function createDefaultPetWindow(options: DefaultPetWindowOptions): BrowserWindow {
const window = createBasePetWindow("OpenPets — Default Pet", options.position);
installMousePassthroughAndDrag(window);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested });
const savePosition = debounce(() => {
if (window.isDestroyed()) {
return;
}
options.onPositionChanged(readWindowPosition(window));
}, 150);
window.on("move", savePosition);
window.on("moved", savePosition);
window.on("close", () => {
options.onPositionChanged(readWindowPosition(window));
});
void loadDefaultPetContent(window, options.paused, options.display);
return window;
}
export function createAgentPetWindow(options: AgentPetWindowOptions): BrowserWindow {
const window = createBasePetWindow(`OpenPets — ${options.displayName}`, options.position);
installMousePassthroughAndDrag(window);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
void loadExplicitPetContent(window, options.petId, options.display);
return window;
}
function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void }): void {
window.webContents.on("context-menu", (event) => {
event.preventDefault();
if (window.isDestroyed()) return;
Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window });
});
}
function installMousePassthroughAndDrag(window: BrowserWindow): void {
let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null;
const isFromWindow = (event: IpcMainEvent): boolean => event.sender === window.webContents;
const setPassthrough = (passthrough: boolean): void => {
if (window.isDestroyed()) return;
if (passthrough) window.setIgnoreMouseEvents(true, { forward: true });
else window.setIgnoreMouseEvents(false);
};
const handleHitTest = (event: IpcMainEvent, interactive: unknown): void => {
if (!isFromWindow(event)) return;
setPassthrough(!interactive && !dragging);
};
const handleDragStart = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !isScreenPoint(point)) return;
const [startWindowX, startWindowY] = window.getPosition();
dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX, startWindowY };
setPassthrough(false);
};
const handleDragMove = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !dragging || !isScreenPoint(point)) return;
window.setPosition(dragging.startWindowX + Math.round(point.screenX - dragging.startScreenX), dragging.startWindowY + Math.round(point.screenY - dragging.startScreenY), false);
};
const handleDragEnd = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
dragging = null;
};
ipcMain.on("openpets:pet-hit-test", handleHitTest);
ipcMain.on("openpets:pet-drag-start", handleDragStart);
ipcMain.on("openpets:pet-drag-move", handleDragMove);
ipcMain.on("openpets:pet-drag-end", handleDragEnd);
window.webContents.once("did-finish-load", () => setPassthrough(true));
window.on("closed", () => {
ipcMain.off("openpets:pet-hit-test", handleHitTest);
ipcMain.off("openpets:pet-drag-start", handleDragStart);
ipcMain.off("openpets:pet-drag-move", handleDragMove);
ipcMain.off("openpets:pet-drag-end", handleDragEnd);
});
}
function isScreenPoint(value: unknown): value is { readonly screenX: number; readonly screenY: number } {
return typeof value === "object" && value !== null && typeof (value as { readonly screenX?: unknown }).screenX === "number" && typeof (value as { readonly screenY?: unknown }).screenY === "number";
}
function createBasePetWindow(title: string, position: Point): BrowserWindow {
const window = new BrowserWindow({
title,
width: defaultPetWindowSize.width,
height: defaultPetWindowSize.height,
x: position.x,
y: position.y,
frame: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
hasShadow: false,
backgroundColor: "#00000000",
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: join(app.getAppPath(), "pet-preload.cjs"),
},
});
window.setMenu(null);
window.setAlwaysOnTop(true, "floating");
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
if (isAllowedPetDocumentUrl(url)) return;
event.preventDefault();
});
window.webContents.on("will-redirect", (event) => {
event.preventDefault();
});
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
console.error("Failed to load default pet window.", { errorCode, errorDescription });
});
window.webContents.on("render-process-gone", (_event, details) => {
console.error("Default pet renderer process gone.", details);
});
return window;
}
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null): Promise<void> {
const html = await createDefaultPetHtml(paused, display);
await loadPetHtmlFile(window, html, "default").catch((error: unknown) => {
console.error("Failed to load default pet URL.", error);
});
}
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null): Promise<void> {
try {
const state = getAppStateSnapshot();
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet || pet.broken || pet.id === builtInPet.id) {
throw new Error(`Cannot render explicit pet: ${petId}`);
}
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue);
await loadPetHtmlFile(window, html, `explicit-${pet.id}`);
} catch (error: unknown) {
console.error(`Failed to load explicit pet ${petId} URL.`, error);
}
}
export function preparePetTransientDisplay(display: PetTransientDisplay): PetTransientDisplay {
if (!display.reaction || display.message || display.reactionMessage) return display;
return { ...display, reactionMessage: pickReactionMessage(display.reaction) };
}
export function mergePetTransientDisplay(current: PetTransientDisplay | null, next: PetTransientDisplay): PetTransientDisplay {
if (next.message || !next.reaction || !current?.message) return preparePetTransientDisplay(next);
return { ...current, reaction: next.reaction };
}
export function getTransientReactionAnimationMs(display: PetTransientDisplay): number | null {
if (!display.reaction) return null;
const state = reactionToSpriteState[display.reaction];
const row = defaultPetSprite.states[state];
const iterations = "iterations" in row ? row.iterations : "infinite";
return typeof iterations === "number" ? row.durationMs * iterations : null;
}
export function clearTransientReaction(display: PetTransientDisplay): PetTransientDisplay {
if (!display.reaction) return display;
return { ...display, reaction: undefined };
}
export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-reaction-state", state);
}
export function getSafeDefaultPetPosition(position: Point | undefined): Point {
return clampToPrimaryWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize);
}
export function readWindowPosition(window: BrowserWindow): Point {
const [x, y] = window.getPosition();
return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize);
}
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string> {
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display);
if (installedPetHtml) {
return installedPetHtml;
}
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
const bubble = createBubbleMarkup(display, paused);
const stateRows = defaultPetSprite.states;
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
return `<!doctype html>
<html lang="en" data-reaction-state="${getReactionSpriteState(display?.reaction)}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<style>
${createPetWindowCss(paused, scale)}
.sprite {
width: ${defaultPetSprite.frameWidth}px;
height: ${defaultPetSprite.frameHeight}px;
background-image: url("${escapeCssUrl(spriteUrl)}");
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
background-repeat: no-repeat;
--sprite-row-y: 0px;
--sprite-frames: ${stateRows.idle.frames};
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".sprite")}
@keyframes pet-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
</style>
</head>
<body>
<div class="stage" aria-label="OpenPets default pet">
${bubble}
<div class="pet-shell">
<div class="sprite" role="img" aria-label="Claude animated default pet"></div>
</div>
</div>
</body>
</html>`;
}
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string | null> {
const state = getAppStateSnapshot();
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
if (!selected || selected.id === builtInPet.id || selected.broken) {
return null;
}
try {
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue);
} catch (error) {
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
try {
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed pet rendering failed.");
} catch (markError) {
console.error(`Failed to mark installed pet ${selected.id} broken.`, markError);
}
return null;
}
}
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue): Promise<string> {
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
throw new Error("Installed pet spritesheet is missing or too large.");
}
const imageUrl = pathToFileURL(spritesheetPath).toString();
const bubble = createBubbleMarkup(display, paused);
const stateRows = defaultPetSprite.states;
return `<!doctype html>
<html lang="en" data-reaction-state="${getReactionSpriteState(display?.reaction)}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<style>
${createPetWindowCss(paused, scale)}
.installed-card { width: ${Math.ceil(defaultPetSprite.frameWidth * scale)}px; height: ${Math.ceil(defaultPetSprite.frameHeight * scale)}px; overflow: visible; position: relative; }
.installed-sprite {
position: absolute;
left: 0;
top: 0;
width: ${defaultPetSprite.frameWidth}px;
height: ${defaultPetSprite.frameHeight}px;
background-image: url("${escapeCssUrl(imageUrl)}");
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
background-repeat: no-repeat;
--sprite-row-y: 0px;
--sprite-frames: ${stateRows.idle.frames};
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".installed-sprite")}
@keyframes pet-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
</style>
</head>
<body>
<div class="stage" aria-label="${escapeHtml(displayName)}">
${bubble}
<div class="pet-shell">
<div class="installed-card" role="img" aria-label="${escapeHtml(displayName)}">
<div class="installed-sprite"></div>
</div>
</div>
</div>
</body>
</html>`;
}
function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
const opacity = paused ? "0.62" : "1";
const playState = paused ? "paused" : "running";
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
const petBottom = 22;
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 3);
return `
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
html { color: #172033; }
body { -webkit-app-region: no-drag; pointer-events: none; }
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 2; box-sizing: border-box; max-width: min(200px, calc(100vw - 14px)); max-height: 92px; padding: 8px 10px; background: #fff; color: #000; font: 700 11px/14px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: left; border: 2px solid #000; border-radius: 4px; box-shadow: 2px 2px 0 #000; white-space: normal; overflow-wrap: anywhere; word-break: normal; overflow: hidden; pointer-events: auto; -webkit-app-region: no-drag; transform: translateX(-50%); opacity: 1; animation: bubble-in 160ms steps(2, end); }
.bubble-text { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; }
.bubble::after, .bubble::before { content: none; }
.bubble.is-reaction { max-width: min(142px, calc(100vw - 10px)); max-height: 64px; padding: 6px 8px; font: 750 11px/13px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: center; overflow-wrap: break-word; }
.bubble.is-reaction .bubble-text { -webkit-line-clamp: 3; }
.bubble.is-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 106px; font-size: 10.5px; line-height: 13px; }
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 7; }
.bubble.is-very-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 126px; font-size: 10px; line-height: 13px; }
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 9; }
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(3px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble { animation: none !important; } }
`;
}
function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string {
const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState));
const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>)
.filter(([motion]) => motion !== "idle")
.map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state));
return [...reactionRules, ...motionRules].join("\n");
}
function createSpriteRule(selector: string, state: UniversalSpriteState): string {
const row = defaultPetSprite.states[state];
const iterations = "iterations" in row ? row.iterations : "infinite";
return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`;
}
function getReactionSpriteState(reaction: OpenPetsReaction | undefined): UniversalSpriteState {
return reaction ? reactionToSpriteState[reaction] : "idle";
}
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean): string {
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
if (!text) return "";
const className = getBubbleClassName(text, Boolean(display?.message && !display?.reactionMessage));
return `<div class="${className}" role="status" aria-live="polite"><span class="bubble-text">${escapeHtml(text)}</span></div>`;
}
function getBubbleClassName(text: string, isExplicitMessage: boolean): string {
if (!isExplicitMessage) return "bubble is-reaction";
const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
return `bubble is-message${lengthClass}`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeCssUrl(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "");
}
function installMotionStatePublisher(window: BrowserWindow): void {
let lastX = window.getPosition()[0];
let lastSent: PetMotionState = "idle";
let idleTimer: NodeJS.Timeout | null = null;
const sendMotionState = (state: PetMotionState): void => {
if (window.isDestroyed() || lastSent === state) return;
lastSent = state;
window.webContents.send("openpets:pet-motion", state);
};
const scheduleIdle = (): void => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
idleTimer = null;
sendMotionState("idle");
}, 180);
};
const handleMove = (): void => {
if (window.isDestroyed()) return;
const [x] = window.getPosition();
const deltaX = x - lastX;
lastX = x;
if (Math.abs(deltaX) >= 3) {
sendMotionState(deltaX > 0 ? "run-right" : "run-left");
}
scheduleIdle();
};
window.on("move", handleMove);
window.on("moved", handleMove);
window.webContents.on("did-finish-load", () => {
lastSent = "idle";
window.webContents.send("openpets:pet-motion", "idle");
});
window.on("closed", () => {
if (idleTimer) clearTimeout(idleTimer);
});
}
function isAllowedPetDocumentUrl(url: string): boolean {
return url.startsWith("data:text/html") || url.startsWith("file://");
}
async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string): Promise<void> {
const dir = join(app.getPath("userData"), "rendered-pets");
await mkdir(dir, { recursive: true });
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet";
const filePath = join(dir, `${safeName}.html`);
await writeFile(filePath, html, "utf8");
await window.loadFile(filePath);
}
function debounce(callback: () => void, delayMs: number): () => void {
let timeout: NodeJS.Timeout | undefined;
return () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(callback, delayMs);
};
}

View file

@ -0,0 +1,251 @@
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
export const reactionMessagePools = {
idle: [
"Hanging out",
"Right here",
"Ready when you are",
"Keeping watch",
"Standing by",
"At your service",
"Waiting patiently",
"Holding position",
"Keeping you company",
"Taking a tiny breather",
"Still here",
"Ready for the next thing",
"Watching the workspace",
"Calm and ready",
"Just vibing",
"No rush",
"Quietly on duty",
"Settled in",
"Available if needed",
"Resting the paws",
],
thinking: [
"Thinking it through",
"Let me think",
"Checking the clues",
"One moment",
"Looking closer",
"Puzzling this out",
"Tracing the idea",
"Connecting the dots",
"Sorting the pieces",
"Reading the room",
"Following the thread",
"Considering options",
"Making a tiny plan",
"Looking for the clean path",
"Running the thought maze",
"Checking assumptions",
"Finding the pattern",
"Thinking carefully",
"Letting the gears turn",
"Studying the situation",
],
working: [
"Working on it",
"Making progress",
"On the case",
"Handling it",
"Steady progress",
"Moving things along",
"Doing the work",
"Chipping away",
"Keeping momentum",
"Building this up",
"Putting pieces together",
"Getting it done",
"Making it happen",
"Carrying the task",
"Tiny paws at work",
"Staying focused",
"Working the problem",
"Pushing forward",
"Keeping busy",
"Taking care of it",
],
editing: [
"Making edits",
"Tuning things up",
"Shaping the changes",
"Polishing this",
"Adjusting the details",
"Refining the edges",
"Cleaning this up",
"Smoothing the rough bits",
"Moving text around",
"Improving the shape",
"Making it tidier",
"Trimming the fluff",
"Sharpening the result",
"Fitting pieces neatly",
"Nudging this into place",
"Reworking a bit",
"Tidying the change",
"Polishing the paws off it",
"Aligning the details",
"Making the diff nicer",
],
running: [
"Running now",
"Here we go",
"Putting it in motion",
"Off and running",
"Letting it run",
"Starting the engine",
"Kicking things off",
"Sending it through",
"Launching the command",
"Giving it a spin",
"Running the task",
"In motion now",
"Wheels are turning",
"Command is underway",
"Letting the machine work",
"On the move",
"Firing it up",
"Executing the plan",
"Making the terminal hum",
"Running the route",
],
testing: [
"Testing this",
"Checking behavior",
"Running the checks",
"Verifying things",
"Looking for issues",
"Putting it through its paces",
"Checking the safety rails",
"Looking for regressions",
"Giving it a careful look",
"Testing the path",
"Checking the result",
"Running the guardrails",
"Sniffing out bugs",
"Making sure it holds",
"Trying to break it gently",
"Watching the tests",
"Confirming the fix",
"Inspecting the behavior",
"Looking for loose ends",
"Verifying the tiny details",
],
waiting: [
"Waiting on you",
"Paused for input",
"Standing by",
"Ready for approval",
"Holding here",
"Need a decision",
"Waiting for the nod",
"Paused at the gate",
"Your call",
"Holding the line",
"Waiting patiently",
"Ready when approved",
"Just need permission",
"Awaiting direction",
"Stopped for a choice",
"Keeping this warm",
"Waiting for your move",
"At the checkpoint",
"Paused with purpose",
"Standing at attention",
],
waving: [
"Hello there",
"Just checking in",
"I see you",
"Tiny wave",
"Over here",
"Little hello",
"Hi hi",
"Friendly ping",
"Waving from the corner",
"Noticed something",
"Attention please",
"Small wave incoming",
"Quick hello",
"Popping in",
"Soft ping",
"Tiny paws waving",
"Hello from OpenPets",
"Checking the window",
"Here with an update",
"A little wave",
],
success: [
"Done",
"All set",
"That worked",
"Nice and tidy",
"Finished up",
"Success",
"That landed",
"Looks good",
"Wrapped up",
"Clean finish",
"Task complete",
"Green lights",
"Everything passed",
"Good to go",
"Nicely done",
"Ready for the next one",
"That came together",
"Finished cleanly",
"Tiny win secured",
"No loose ends found",
],
error: [
"Something broke",
"Hit a snag",
"That needs a look",
"Not quite there",
"Trouble spotted",
"Found a problem",
"That did not pass",
"Oops, a bump",
"Needs attention",
"Something complained",
"The path got bumpy",
"This needs fixing",
"A check failed",
"Not happy yet",
"Found a rough edge",
"There is a hiccup",
"That went sideways",
"Issue detected",
"Tiny alarm bells",
"Let us inspect this",
],
celebrating: [
"Tiny victory",
"We did it",
"Worth a little dance",
"That deserves a cheer",
"Celebrating this",
"Little party time",
"Happy paws",
"Victory wiggle",
"That feels good",
"Small celebration",
"Confetti in spirit",
"Cheering quietly",
"That is a win",
"Nice little triumph",
"Joyful moment",
"Paws in the air",
"Tiny parade",
"We earned this",
"Celebration mode",
"Happy little bounce",
],
} as const satisfies Record<OpenPetsReaction, readonly string[]>;
export function pickReactionMessage(reaction: OpenPetsReaction, random: () => number = Math.random): string {
const pool = reactionMessagePools[reaction];
return pool[Math.floor(random() * pool.length) % pool.length] ?? reaction;
}

12
apps/desktop/src/state.ts Normal file
View file

@ -0,0 +1,12 @@
export interface ShellState {
paused: boolean;
}
export const shellState: ShellState = {
paused: false,
};
export function togglePaused(): boolean {
shellState.paused = !shellState.paused;
return shellState.paused;
}

110
apps/desktop/src/tray.ts Normal file
View file

@ -0,0 +1,110 @@
import { Menu, Tray, type MenuItemConstructorOptions } from "electron";
import { getAppStateSnapshot, isOnboardingCompleted } from "./app-state.js";
import { createTrayIcon } from "./assets.js";
import { hideDefaultPet, isDefaultPetVisible, setDefaultPetPaused, showDefaultPet } from "./default-pet-controller.js";
import { quitOpenPets } from "./lifecycle.js";
import { shellState, togglePaused } from "./state.js";
import { getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";
import { openTaskWindow } from "./windows.js";
let tray: Tray | null = null;
export function createAppTray(): Tray {
if (tray) {
return tray;
}
tray = new Tray(createTrayIcon());
tray.setToolTip("OpenPets");
refreshTrayMenu();
console.log("OpenPets tray created.");
return tray;
}
export function refreshTrayMenu(): void {
if (!tray) {
return;
}
const state = getAppStateSnapshot();
const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId && !pet.broken) ?? state.pets.installed[0];
const defaultPetName = defaultPet?.displayName ?? "Built-in Pet";
const continueSetupItems = isOnboardingCompleted()
? []
: [
{
label: "Continue Setup...",
click: () => openTaskWindow("onboarding"),
},
{ type: "separator" as const },
];
const menu = Menu.buildFromTemplate([
{
label: "OpenPets",
enabled: false,
},
...createUpdateMenuItems(),
{ type: "separator" },
...continueSetupItems,
{
label: `Default Pet: ${defaultPetName}`,
click: () => openTaskWindow("pet-manager"),
},
{
label: isDefaultPetVisible() ? "Hide Default Pet" : "Show Default Pet",
click: () => {
if (isDefaultPetVisible()) {
hideDefaultPet();
} else {
showDefaultPet();
}
refreshTrayMenu();
},
},
{
label: shellState.paused ? "Resume All Pets" : "Pause All Pets",
click: () => {
const paused = togglePaused();
setDefaultPetPaused(paused);
console.log(paused ? "OpenPets paused." : "OpenPets resumed.");
refreshTrayMenu();
},
},
{ type: "separator" },
{
label: "Manage Pets...",
click: () => openTaskWindow("pet-manager"),
},
{
label: "Integrations...",
click: () => openTaskWindow("agent-setup"),
},
{
label: "Settings...",
click: () => openTaskWindow("settings"),
},
{ type: "separator" },
{
label: "Quit OpenPets",
click: () => quitOpenPets(),
},
]);
tray.setContextMenu(menu);
}
function createUpdateMenuItems(): MenuItemConstructorOptions[] {
const status = getUpdateStatus();
if (status.state !== "available") return [];
return [
{
label: `Update available: ${status.latestVersion ?? "latest"}...`,
click: () => { void openUpdateReleasePage(); },
},
];
}

View file

@ -0,0 +1,124 @@
import { app, shell } from "electron";
import https from "node:https";
import { createParsedUpdateStatus, normalizeVersion } from "./update-version.js";
export type UpdateStatusState = "idle" | "checking" | "available" | "current" | "error";
export interface UpdateStatus {
readonly state: UpdateStatusState;
readonly currentVersion: string;
readonly latestVersion?: string;
readonly releaseUrl?: string;
readonly checkedAt?: number;
readonly error?: string;
}
interface GitHubReleaseResponse {
readonly tag_name?: unknown;
readonly name?: unknown;
readonly html_url?: unknown;
}
const githubRepository = process.env.OPENPETS_GITHUB_REPOSITORY || "alvinunreal/openpets";
const latestReleaseApiUrl = `https://api.github.com/repos/${githubRepository}/releases/latest`;
const releasesPageUrl = `https://github.com/${githubRepository}/releases`;
const releaseCheckTimeoutMs = 6_000;
let updateStatus: UpdateStatus = { state: "idle", currentVersion: getCurrentAppVersion() };
let checkInFlight: Promise<UpdateStatus> | null = null;
export function getUpdateStatus(): UpdateStatus {
return updateStatus;
}
export async function checkForGitHubReleaseUpdate(): Promise<UpdateStatus> {
if (checkInFlight) return checkInFlight;
updateStatus = { ...updateStatus, state: "checking", currentVersion: getCurrentAppVersion() };
checkInFlight = fetchLatestRelease()
.then((release) => {
const currentVersion = getCurrentAppVersion();
updateStatus = createUpdateStatusFromLatestRelease(currentVersion, release, Date.now());
return updateStatus;
})
.catch((error: unknown) => {
updateStatus = createUpdateErrorStatus(getCurrentAppVersion(), error, Date.now());
return updateStatus;
})
.finally(() => {
checkInFlight = null;
});
return checkInFlight;
}
export async function openUpdateReleasePage(): Promise<void> {
const url = validateGitHubReleaseUrl(updateStatus.releaseUrl) || releasesPageUrl;
await shell.openExternal(url);
}
export function createUpdateStatusFromLatestRelease(currentVersion: string, release: GitHubReleaseResponse, checkedAt: number): UpdateStatus {
return createParsedUpdateStatus(currentVersion, release, checkedAt, githubRepository, releasesPageUrl);
}
export function createUpdateErrorStatus(currentVersion: string, error: unknown, checkedAt: number): UpdateStatus {
return {
state: "error",
currentVersion,
checkedAt,
error: error instanceof Error ? error.message : "Update check failed.",
};
}
function getCurrentAppVersion(): string {
return normalizeVersion(app.getVersion()) || "0.0.0";
}
function validateGitHubReleaseUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" || url.hostname !== "github.com") return null;
if (!url.pathname.startsWith(`/${githubRepository}/releases/`)) return null;
return url.toString();
} catch {
return null;
}
}
function fetchLatestRelease(): Promise<GitHubReleaseResponse> {
return new Promise((resolve, reject) => {
const request = https.get(latestReleaseApiUrl, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": `OpenPets/${getCurrentAppVersion()}`,
},
timeout: releaseCheckTimeoutMs,
}, (response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk: string) => {
body += chunk;
if (Buffer.byteLength(body, "utf8") > 128 * 1024) {
request.destroy(new Error("GitHub release response is too large."));
}
});
response.on("end", () => {
if (response.statusCode === 404) {
reject(new Error("No public OpenPets releases found yet."));
return;
}
if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) {
reject(new Error(`GitHub release check failed with HTTP ${response.statusCode ?? "unknown"}.`));
return;
}
try {
resolve(JSON.parse(body) as GitHubReleaseResponse);
} catch {
reject(new Error("GitHub release response was not valid JSON."));
}
});
});
request.on("timeout", () => request.destroy(new Error("GitHub release check timed out.")));
request.on("error", reject);
});
}

View file

@ -0,0 +1,62 @@
export interface UpdateStatusFromReleaseInput {
readonly tag_name?: unknown;
readonly name?: unknown;
readonly html_url?: unknown;
}
export interface ParsedUpdateStatus {
readonly state: "available" | "current";
readonly currentVersion: string;
readonly latestVersion: string;
readonly releaseUrl: string;
readonly checkedAt: number;
}
export function normalizeVersion(value: unknown): string | null {
if (typeof value !== "string") return null;
const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` : null;
}
export function createParsedUpdateStatus(currentVersion: string, release: UpdateStatusFromReleaseInput, checkedAt: number, repository: string, releasesPageUrl: string): ParsedUpdateStatus {
const latestVersion = normalizeVersion(release.tag_name) || normalizeVersion(release.name);
if (!latestVersion) throw new Error("Latest GitHub release did not include a valid version tag.");
const releaseUrl = validateGitHubReleaseUrl(release.html_url, repository) || releasesPageUrl;
return {
state: isVersionNewer(latestVersion, currentVersion) ? "available" : "current",
currentVersion,
latestVersion,
releaseUrl,
checkedAt,
};
}
function validateGitHubReleaseUrl(value: unknown, repository: string): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" || url.hostname !== "github.com") return null;
if (!url.pathname.startsWith(`/${repository}/releases/`)) return null;
return url.toString();
} catch {
return null;
}
}
export function isVersionNewer(candidate: string, current: string): boolean {
const nextParts = parseVersionParts(candidate);
const currentParts = parseVersionParts(current);
if (!nextParts || !currentParts) return false;
for (let index = 0; index < nextParts.length; index += 1) {
if (nextParts[index] > currentParts[index]) return true;
if (nextParts[index] < currentParts[index]) return false;
}
return false;
}
function parseVersionParts(value: string): readonly [number, number, number] | null {
const version = normalizeVersion(value);
if (!version) return null;
const [major, minor, patch] = version.split(".").map(Number);
return Number.isInteger(major) && Number.isInteger(minor) && Number.isInteger(patch) ? [major, minor, patch] : null;
}

1154
apps/desktop/src/windows.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
import { resolve, sep } from "node:path";
export interface SafeZipEntryPath {
readonly originalName: string;
readonly normalizedName: string;
readonly topLevelDirectory: string;
readonly relativeOutputPath: "pet.json" | "spritesheet.webp" | null;
readonly isDirectory: boolean;
}
export class ZipEntryPathTracker {
readonly #normalizedPaths = new Set<string>();
readonly #caseFoldedPaths = new Set<string>();
#topLevelDirectory: string | null = null;
accept(fileName: string): SafeZipEntryPath {
const entry = validateZipEntryName(fileName);
if (this.#topLevelDirectory !== null && entry.topLevelDirectory !== this.#topLevelDirectory) {
throw new Error("Zip contains mixed or multiple top-level layouts.");
}
this.#topLevelDirectory = entry.topLevelDirectory;
if (this.#normalizedPaths.has(entry.normalizedName)) {
throw new Error(`Duplicate zip entry path: ${entry.normalizedName}`);
}
const caseFolded = entry.normalizedName.toLocaleLowerCase("en-US");
if (this.#caseFoldedPaths.has(caseFolded)) {
throw new Error(`Case-insensitive zip entry collision: ${entry.normalizedName}`);
}
this.#normalizedPaths.add(entry.normalizedName);
this.#caseFoldedPaths.add(caseFolded);
return entry;
}
}
export function validateZipEntryName(fileName: string): SafeZipEntryPath {
if (fileName.includes("\0")) throw new Error("Zip entry contains NUL byte.");
if (fileName.includes("\\")) throw new Error("Zip entry contains backslash separator.");
if (fileName.startsWith("/") || fileName.startsWith("//")) throw new Error("Zip entry is absolute.");
if (/^[a-zA-Z]:\//.test(fileName)) throw new Error("Zip entry contains Windows drive path.");
if (fileName.includes("//")) throw new Error("Zip entry contains empty path segment.");
const parts = fileName.split("/").filter(Boolean);
if (parts.some((part) => part === "..")) throw new Error("Zip entry contains parent traversal.");
if (parts.some((part) => part === ".")) throw new Error("Zip entry contains current-directory segment.");
const isDirectory = fileName.endsWith("/");
if (isDirectory) {
if (parts.length !== 1) throw new Error("Zip directory layout is unsupported.");
return {
originalName: fileName,
normalizedName: parts.join("/"),
topLevelDirectory: parts[0] ?? "",
relativeOutputPath: null,
isDirectory: true,
};
}
if (parts.length !== 1 && parts.length !== 2) throw new Error("Zip must contain pet files at the root or under exactly one top-level directory.");
const leaf = parts.at(-1);
if (leaf !== "pet.json" && leaf !== "spritesheet.webp") {
throw new Error(`Unexpected zip file: ${leaf}`);
}
return {
originalName: fileName,
normalizedName: parts.join("/"),
topLevelDirectory: parts.length === 1 ? "" : parts[0] ?? "",
relativeOutputPath: leaf,
isDirectory: false,
};
}
export function assertOutputPathInside(tempDir: string, outputPath: string): void {
const root = resolve(tempDir);
const target = resolve(outputPath);
if (target !== root && !target.startsWith(`${root}${sep}`)) {
throw new Error("Zip entry output path escapes temp directory.");
}
}
export function hasSupportedZipMagic(buffer: Buffer): boolean {
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
return false;
}
const signature = buffer.readUInt32LE(0);
return signature === 0x04034b50 || signature === 0x06054b50;
}
export function assertRejectsPath(fileName: string): void {
try {
validateZipEntryName(fileName);
} catch {
return;
}
throw new Error(`Unsafe zip path was accepted: ${fileName}`);
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

238
docs/claude-integration.md Normal file
View file

@ -0,0 +1,238 @@
# Claude integration
This document describes how OpenPets Desktop integrates with Claude Code, what files are configured, how Claude reaches the desktop app, and which Claude events trigger pet reactions or speech.
## Integration surfaces
Claude support has three related surfaces:
1. **MCP tools** — Claude gets the `openpets_status`, `openpets_react`, and `openpets_say` tools.
2. **Claude memory instructions** — Claude is told when it should use those tools.
3. **Claude hooks** — Claude lifecycle and tool events automatically trigger OpenPets reactions.
The desktop setup UI for these surfaces is implemented in `apps/desktop/src/agent-setup.ts` and exposed to the renderer through `openpets:agent-setup-snapshot` and `openpets:agent-setup-action`.
This document primarily describes **Desktop user/global setup**. The CLI also supports **project-local Claude setup**; see [Project-local Claude setup](#project-local-claude-setup).
## Files managed by OpenPets
### Claude MCP entry
The MCP entry is installed into Claude Code by running a Claude command shaped like:
```sh
claude mcp add --scope user openpets -- npx -y @open-pets/mcp
```
If the user selected a specific pet in the desktop setup UI, the command includes that pet:
```sh
claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet <petId>
```
In local development or packaged desktop mode, OpenPets can use `node <local-or-bundled-entry>` instead of `npx`.
Packaged and local `node <entry>` commands require `node` to be available on Claude Code's `PATH`. Desktop setup reports an error instead of installing a bundled command if `node --version` cannot run from the agent environment.
### Claude memory
OpenPets writes two user-level Claude memory files:
```text
~/.claude/CLAUDE.md
~/.claude/openpets.md
```
`~/.claude/CLAUDE.md` receives a managed import block:
```md
<!-- OPENPETS:IMPORT:START -->
@~/.claude/openpets.md
<!-- OPENPETS:IMPORT:END -->
```
`~/.claude/openpets.md` receives the managed instruction block:
```md
<!-- OPENPETS:START -->
## OpenPets
OpenPets MCP tools may be available.
Use OpenPets as a short visible status channel for meaningful coding progress:
- Use `openpets_say` when starting, completing, blocking, or needing review on non-trivial work.
- Keep messages brief, user-facing, and non-sensitive.
- Do not include code, logs, secrets, URLs, or file paths.
- Use `openpets_react` for small visual or emotional feedback.
- Use `openpets_status` only when checking availability or the targeted pet.
- Do not spam every internal step.
<!-- OPENPETS:END -->
```
The memory installer is idempotent. It updates only the managed blocks, avoids symlinks and unsafe files, writes private files, and uses atomic temp-file writes.
### Claude hooks
Claude hooks are installed into:
```text
~/.claude/settings.json
```
OpenPets-managed hook commands include this marker:
```text
--openpets-managed
```
The normal published hook command is:
```sh
npx -y @open-pets/claude hook --openpets-managed
```
If a pet is selected:
```sh
npx -y @open-pets/claude hook --openpets-managed --pet <petId>
```
OpenPets installs the command for these Claude hook events:
```text
UserPromptSubmit
PreToolUse
PermissionRequest
Notification
Stop
StopFailure
```
Each hook entry is a Claude command hook with a short timeout and async execution enabled:
```json
{
"type": "command",
"command": "npx -y @open-pets/claude hook --openpets-managed",
"timeout": 3,
"async": true,
"asyncRewake": false
}
```
OpenPets backs up `settings.json` before changing it and removes only hooks containing the `--openpets-managed` marker.
## Project-local Claude setup
The `@open-pets/cli` package can configure a project-local Claude integration from a project directory:
```sh
openpets configure --agent claude --pet <petId>
```
Project-local setup differs from Desktop user/global setup:
- It uses `claude mcp add-json openpets ... --scope local` from the target project directory.
- It writes hooks to `<project>/.claude/settings.local.json`.
- Hook commands include both `--openpets-managed` and `--project-local`.
- Project-local hook entries use `timeout: 10`, `async: true`, and `asyncRewake: false`.
- The MCP and hooks are always configured with a selected `--pet <petId>`.
When a global OpenPets Claude hook runs, it checks whether the current Claude project already has a project-local OpenPets hook. If it finds one, the global hook does not send a duplicate reaction. This avoids double pet events when both global Desktop setup and project-local CLI setup exist.
Project-local files live inside the project and may be committed depending on the user's repository policy. They can contain the selected pet id.
## Runtime path
When Claude calls an OpenPets tool or when a Claude hook fires, the runtime path is:
```text
Claude Code
-> @open-pets/mcp or @open-pets/claude hook
-> @open-pets/client
-> OpenPets desktop local IPC discovery file
-> OpenPets desktop IPC socket/pipe
-> default pet controller or explicit agent pet controller
```
The desktop app writes a discovery file such as:
```text
macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json
Windows: %APPDATA%/OpenPets/runtime/ipc.json
Linux: $XDG_RUNTIME_DIR/openpets/ipc.json, or ~/.config/OpenPets/runtime/ipc.json fallback
```
That file contains the IPC endpoint and a per-run token. Clients must send that token with every request.
## MCP tools
Claude sees these tools when the MCP server is configured:
| Tool | Purpose | Desktop IPC method |
| --- | --- | --- |
| `openpets_status` | Check whether OpenPets is reachable and which pet is targeted. | `status` |
| `openpets_react` | Set a short reaction on the target pet. | `pet.react` |
| `openpets_say` | Show a short safe speech bubble, optionally with a reaction. | `pet.say` |
`openpets_say` is validated before it reaches the desktop app. Messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths.
## Pet targeting
If the MCP server or hook command was configured without `--pet`, events target the desktop default pet.
If it was configured with `--pet <petId>`, the process asks the desktop app for a lease. A valid installed non-default pet opens as an explicit agent pet window. Missing, invalid, broken, built-in, or default pet requests fall back to the default pet.
Explicit leases expire after a short TTL unless refreshed. When the last explicit lease for a pet expires or is released, OpenPets clears and closes that agent pet window.
## Claude hook reaction and speech mapping
Claude hooks are decorative and best-effort. They must not block, approve, deny, or change Claude's behavior. If OpenPets is closed or IPC is unavailable, the hook exits successfully and silently.
| Claude event | Trigger condition | Reaction | Speech bubble |
| --- | --- | --- | --- |
| `UserPromptSubmit` | User submits a prompt to Claude. | `thinking`, throttled. | None. |
| `PreToolUse` | Tool name is `Edit`, `Write`, or `MultiEdit`. | `editing` | None. |
| `PreToolUse` | Tool name is `Bash` and command text looks test-like. | `testing` | None. |
| `PreToolUse` | Tool name is `Bash` and command text does not look test-like. | None. | None. |
| `PreToolUse` | Any other tool. | None. | None. |
| `PermissionRequest` | Claude asks for approval. | `waiting` | `Approval needed`, throttled with a short cooldown. |
| `Notification` | Claude emits a notification hook event. | None. | None. |
| `Stop` | Claude finishes a response. | `success`, throttled. | None. |
| `StopFailure` | Claude stop/finalization fails. | `error` | One error-pool message, throttled. |
| Unknown event | Event is not recognized. | None | None. |
Test-like Bash detection is intentionally coarse and private. It may inspect only a bounded command string to classify the reaction. The command text is never sent to speech.
Claude hooks do **not** use the left/right walking animations or the `running` reaction. The `running-right` and `running-left` sprite rows are reserved for pet drag/move motion, and generic Bash/shell activity is silent by default. Hook reactions use quieter phase changes instead: editing maps to the generic active-work row, thinking maps to `review`, waiting/testing maps to `waiting`, success maps to `jumping`, and errors map to `failed`.
## Hook speech pools
When a hook is allowed to speak, it chooses one static local message from the matching pool:
| Category | Possible messages |
| --- | --- |
| `thinking` | `Thinking it through`, `Let me check`, `On it`, `Working it out` |
| `success` | `Done`, `That worked`, `All set`, `Nice, finished` |
| `error` | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` |
| `permission` | `Approval needed` |
Speech is throttled by category so hooks do not spam bubbles. Current speech cooldowns are:
| Category | Cooldown |
| --- | --- |
| `permission` | 3 seconds |
| `thinking`, `success`, `error` | 20 seconds each |
Normal thinking and success hooks no longer speak, so their speech pools are retained for shared/static message support but are not used by Claude's default quiet hook policy. Reaction-only hooks are also deduped, with the same reaction suppressed for about 10 seconds.
Throttle state stores only speech-category and reaction timestamp keys, never prompts, commands, tool input, output, code, logs, or transcripts.
## Safety rules
- Hook stdout stays empty so Claude does not ingest accidental context.
- Hook errors are swallowed unless debug logging is enabled.
- Hook debug logs sanitize path-like values. MCP/client-side validation separately rejects secret-looking speech.
- Speech is static and local; it does not include model-generated text.
- Tool input and command text are used only for coarse reaction classification.
- Managed setup refuses unsafe symlinks and non-regular files. Claude memory files also have an oversized-file safety limit before OpenPets edits them.

View file

@ -0,0 +1,355 @@
# OpenPets 2.0 implementation process
## Goal
OpenPets 2.0 should be implemented in clear, manually verifiable phases.
The process should optimize for:
- Great developer experience.
- Smooth incremental progress.
- Working software after each phase.
- Manual user confirmation before moving to the next numbered phase or meaningful user-visible sub-phase.
- Oracle review before implementation plans are accepted.
- Oracle review after implementation before asking the user to confirm.
This is not a big-bang implementation. Each phase should be large enough to be meaningful, but small enough that the user can manually verify the result with confidence.
## Core workflow for every phase
Each phase should follow the same loop:
1. Define the phase goal and manual acceptance criteria.
2. Scout the current codebase and relevant v1 references.
3. Check current documentation for important Electron, Node, MCP, Claude, or platform behavior when needed.
4. Draft the phase implementation plan.
5. Send the plan to Oracle for architecture/process review.
6. Fix the plan based on Oracle feedback.
7. Implement the phase.
8. Run automated checks that fit the phase.
9. Send the implemented diff to Oracle for code/maintainability review.
10. Fix actionable Oracle feedback.
11. Provide the user a concise manual verification guide.
12. Wait for user confirmation before starting the next numbered phase, or any meaningful sub-phase that changes user-visible behavior.
## Global definition of done for each phase
A phase is not done until all relevant items below are true:
- The agreed acceptance criteria for the phase are met.
- Relevant automated checks pass.
- The manual verification guide is written with exact commands, actions, and expected results.
- Known limitations are documented.
- Persistent/config-changing behavior has rollback, uninstall, or restore coverage.
- Security-sensitive changes receive explicit review.
- Oracle implementation review has been completed.
- Oracle feedback disposition is recorded: fixed, deferred, or rejected with reason.
- No unrelated refactors or silent scope expansions are included.
- The user has confirmed the phase manually before moving on.
## Oracle review packet requirements
Before implementation, the phase plan sent to Oracle should include:
- Goal.
- Non-goals.
- Files/packages expected to change.
- Architecture choices and tradeoffs.
- Security/privacy concerns.
- Automated test/check plan.
- Manual acceptance criteria.
After implementation, the diff sent to Oracle should include:
- Summary of implemented changes.
- Diff or exact changed file list.
- Test/check results.
- Known limitations.
- Deviations from the approved plan, if any.
- Specific concerns where review should focus.
## Scope-control rule
If implementation reveals that a phase is larger than expected, stop and split the phase rather than silently continuing.
Do not let Oracle feedback or discovered complexity turn one phase into a much larger phase unless the user explicitly agrees.
## Phase sizing rule
Phases should be relevant and manually testable.
Avoid phases that are too small, such as:
- Rename one file.
- Add one empty package.
- Add one isolated helper with no visible outcome.
Avoid phases that are too large, such as:
- Build all Electron, MCP, Claude, pet catalog, onboarding, and packaging in one pass.
- Implement all supported agents before Claude works end-to-end.
A good phase should produce a concrete checkpoint the user can confirm, for example:
- “I can launch the desktop app and see the tray menu.”
- “I can see and drag the default pet.”
- “Claude Code is detected correctly on my machine.”
- “Claude Code configuration preview looks correct and backups are created.”
- “A real Claude session makes the pet react.”
## Proposed implementation phases
### Phase 0: V2 workspace foundation and DX
Goal:
Create the fresh v2 workspace structure and developer workflow without building product features yet.
Acceptance criteria:
- V2 workspace exists separately from v1 reference code.
- Development uses Node-based tooling and does not require Bun at runtime.
- Basic scripts are clear and documented.
- Type checking/lint/build commands are defined.
- The package shape matches the planned `@open-pets/*` architecture.
Manual confirmation:
- User can install dependencies and run the basic workspace checks.
### Phase 1: Tray-first desktop shell
Goal:
Create the Electron desktop app shell with tray/menu behavior and task-specific placeholder windows.
Acceptance criteria:
- App launches as a tray/menu bar app.
- Tray menu matches the agreed initial menu.
- Manage Pets, Configure Agents, and Settings open placeholder windows.
- Quit exits cleanly.
Manual confirmation:
- User can launch the app, inspect tray/menu items, open placeholder windows, and quit cleanly.
### Phase 2: Default pet window foundation
Goal:
Show the bundled default pet reliably as a floating desktop companion.
Acceptance criteria:
- Bundled default pet appears on launch.
- Pet window is frameless/transparent/always-on-top where supported.
- Pet does not steal focus from the editor/terminal.
- Pet is draggable.
- Pet position is persisted.
- Show/hide default pet works from tray.
- Pause/resume all pets has visible behavior.
- Platform-specific limitations for transparency, focus, dragging, and always-on-top behavior are documented.
Manual confirmation:
- User can see, drag, hide/show, pause/resume, restart, and confirm the pet position persists.
### Phase 3: Local app state and pet manager basics
Goal:
Make pet ownership and default-pet behavior real in the app.
Acceptance criteria:
- App persists core preferences.
- Built-in pet is protected from removal.
- Pet Manager can show installed pets.
- User can set default pet among installed pets.
- Settings can control important early preferences such as speech enabled and open default pet on launch.
Manual confirmation:
- User can change default-related settings, restart app, and see preferences persist.
### Phase 4: V2 pet catalog and installation
Goal:
Support browsing and installing remote pets from the v2 catalog flow.
Acceptance criteria:
- App fetches the v2 catalog.
- Pet Manager shows catalog pets with loading/error states.
- User can install a pet from zip.
- Installed pet is validated before use.
- Zip install path prevents zip-slip/path traversal.
- Zip size/file count limits exist.
- Installation never executes scripts from downloaded pets.
- Failed installs clean up partial files.
- User can remove removable installed pets.
- User can set installed pet as default.
- App remains usable if catalog fetch fails.
Manual confirmation:
- User can install a real catalog pet, set it as default, restart, and see it work.
### Phase 5: Local IPC and `@open-pets/client`
Goal:
Create the private local control plane between adapters and the desktop app.
Acceptance criteria:
- Desktop app exposes local-only IPC.
- Discovery file is written while app is running.
- Discovery file location and permissions are documented per platform.
- Startup token/version validation exists.
- Token lifetime and stale discovery cleanup are defined.
- Local-only threat model is documented.
- `@open-pets/client` can connect and call status/say/react-style methods.
- Invalid token/version requests are rejected.
Manual confirmation:
- User/developer can run a local test command and see the pet react through IPC.
### Phase 6: `@open-pets/mcp` agent-neutral integration
Goal:
Make OpenPets usable from MCP-capable coding agents through the minimal public tool set.
Acceptance criteria:
- MCP server runs with Node/npx.
- Tools exist: `openpets_status`, `openpets_say`, `openpets_react`.
- MCP startup immediately acquires a lease and opens/shows the configured/default pet.
- `--pet` chooses target pet.
- Missing explicit pet falls back to default and reports fallback.
- If app is installed but not running, MCP attempts to launch it.
- If app is missing, MCP stays alive in degraded mode and reports clearly.
- Lease cleanup works on process exit and TTL fallback.
Manual confirmation:
- User can run MCP manually or from a supported agent and see status/say/react work.
### Phase 7: Claude Code detection and configuration
Goal:
Implement the dedicated Claude Code setup experience before full hook behavior.
Acceptance criteria:
- Agent Setup detects Claude Code on the user's machine where possible.
- Shows detected/not detected/configured/needs setup/error states.
- Shows exactly what configuration changes would be made.
- Requires confirmation before editing config.
- Creates backups before editing config.
- Config edits are idempotent.
- Config writes are atomic where practical.
- Dry-run/diff preview exists before writes.
- Backup restore behavior is defined.
- Expected Claude config paths are documented per platform.
- Doctor/check reports actionable status.
- Uninstall/remove integration path exists for changes made by OpenPets.
- Uninstall verification confirms OpenPets-managed config was removed without damaging unrelated user config.
Manual confirmation:
- User can verify Claude Code detection, preview config changes, apply setup, inspect backup behavior, run doctor, and remove integration if needed.
### Phase 8: Full Claude enhanced hooks integration
Goal:
Make Claude Code feel polished through hook-driven reactions and short safe speech.
Acceptance criteria:
- `@open-pets/claude` installs global Claude hooks safely.
- Hook events map to agreed reactions.
- Speech comes from local short message pools/templates.
- Speech is throttled/selective.
- Permission/approval cases use clear fixed messages.
- Notification hooks do not speak by default.
- Speech safety rules are enforced.
- Hooks route to active Claude MCP leases when reliable and otherwise fall back to default pet.
- Hook event contract is documented.
- Ambiguous Claude/MCP/app routing states have defined fallback behavior.
Manual confirmation:
- User runs a real Claude Code session and verifies thinking/working/testing/waiting/success/error reactions plus short safe speech.
### Phase 9: First-run onboarding end-to-end
Goal:
Connect the already-working pieces into the agreed first-run experience.
Acceptance criteria:
- First-run onboarding opens appropriately.
- Default pet appears early.
- Optional pet install step works or gracefully skips on catalog failure.
- Agent setup step uses the real Agent Setup flow.
- Done state clearly says OpenPets is ready.
- Onboarding completion persists.
Manual confirmation:
- User can reset onboarding state, run through onboarding, skip or complete steps, and confirm the app lands in a ready state.
### Phase 10: MVP hardening and packaging
Goal:
Prepare the complete Claude-focused MVP for real use.
Acceptance criteria:
- Concrete platform validation matrix is defined for macOS, Windows, and Linux.
- Packaging path is tested against the agreed validation matrix.
- App lifecycle edge cases are handled.
- IPC cleanup and stale discovery behavior are reliable.
- Pet install failure cases are understandable.
- Claude install/uninstall/doctor paths are robust.
- Core automated tests/checks pass.
- Manual MVP demo checklist passes.
Manual confirmation:
- User can install or run the packaged app and complete the full MVP demo: app launch, default pet, pet install/default change, Claude setup, real Claude reactions/speech, cleanup, quit.
## Oracle review policy
Oracle should review two things for each meaningful phase:
1. The phase plan before implementation begins.
2. The implemented diff before the user is asked for manual confirmation.
Oracle feedback should be handled as follows:
- Fix correctness, safety, maintainability, data-loss, security, and architecture issues before user confirmation.
- Discuss optional scope expansions with the user instead of silently expanding the phase.
- Record Oracle feedback disposition as fixed, deferred, or rejected with reason.
- Do not let Oracle feedback turn one phase into a much larger phase unless the user agrees.
## User confirmation policy
After implementation and Oracle fixes, the user should receive:
- What changed.
- What commands to run.
- What manual behavior to verify.
- Known limitations for this phase.
- A clear question asking whether to proceed to the next phase.
Do not proceed to the next numbered phase, or any meaningful sub-phase that changes user-visible behavior, until the user confirms the current checkpoint is acceptable.

252
docs/mapping.md Normal file
View file

@ -0,0 +1,252 @@
# OpenPets reaction, speech, and Claude hook mapping
This document explains how OpenPets decides which pet reaction is shown, when the pet says something, and how Claude Code hooks and OpenCode plugin events map to reactions/messages.
## Important terms
- **Reaction**: one of the allowed OpenPets reaction names, for example `thinking`, `success`, or `error`.
- **Speech/message**: a short text bubble sent with `openpets_say` or generated by a Claude hook.
- **Hook speech**: automatic short speech generated by Claude Code hook events.
- **Target pet**: the pet that receives the reaction/message. This is either the desktop default pet or a project/agent-selected pet through a lease.
## Allowed reactions
OpenPets currently accepts these reactions:
| Reaction | Intended meaning |
| --- | --- |
| `idle` | Neutral / no special task state. |
| `thinking` | The agent is thinking or starting to process a prompt. |
| `working` | Generic tool/work activity. |
| `editing` | The agent is editing or writing files. |
| `running` | The agent is running a command that is not classified as tests. |
| `testing` | The agent is running tests. |
| `waiting` | The agent is waiting for permission or another blocking state. |
| `waving` | The pet waves for notifications/attention. |
| `success` | The agent completed successfully. |
| `error` | The agent failed or hit a problem. |
| `celebrating` | Positive manual reaction; not currently emitted by Claude hooks. |
Source of truth: `packages/client/src/protocol.ts` and `apps/desktop/src/local-ipc-protocol.ts`.
## What a reaction visually does today
Reactions drive the pet spritesheet animation and can also show temporary bubble text above the pet. For example, `thinking` uses the review/thinking animation row and, when sent as a reaction-only event, displays a short randomized status line such as `Thinking it through` or `Checking the clues`.
The universal Codex/OpenPets spritesheet has these animation rows:
| Row | Spritesheet state | Trigger/reaction mapping |
| --- | --- | --- |
| 0 | `idle` | No active reaction, message-only speech, or `idle`. Slow 5500ms loop. |
| 1 | `running-right` | Drag right via motion state `run-right`. |
| 2 | `running-left` | Drag left via motion state `run-left`. |
| 3 | `waving` | `waving`, Claude `Notification`. |
| 4 | `jumping` | `success`, `celebrating`. |
| 5 | `failed` | `error`. |
| 6 | `waiting` | `waiting`, `testing`, `PermissionRequest`. |
| 7 | `running` | `working`, `editing`, `running`. |
| 8 | `review` | `thinking`. |
Drag motion overrides reaction animation while the pet is being moved. When dragging stops, the sprite returns to the active reaction animation until the transient display clears, then returns to idle.
One-shot feedback animations do not loop for the full 4-second bubble lifetime:
| Spritesheet state | Iterations |
| --- | --- |
| `waving` | 2 loops |
| `jumping` | 2 loops |
| `failed` | 2 loops |
Long-running states (`idle`, `waiting`, `running`, `review`, and drag run states) continue looping while active.
## Bubble priority
When a transient display has both a message and a reaction, the message wins.
| Input | Bubble text shown |
| --- | --- |
| Reaction only: `openpets_react({ reaction: "testing" })` | A stable randomized status line from the `testing` message pool, for example `Running the checks`. |
| Message only: `openpets_say({ message: "Done" })` | `Done` |
| Message + reaction: `openpets_say({ message: "Done", reaction: "success" })` | `Done` |
The reaction is still sent with the message, but the visible bubble text is the message.
Source of truth: `apps/desktop/src/pet-window.ts` (`createBubbleMarkup`).
## How long bubbles stay visible
Reaction/message bubbles are transient and clear after:
```text
4 seconds
```
Source of truth: `apps/desktop/src/local-ipc-protocol.ts` (`transientDisplayMs = 4_000`).
## Claude hook event mapping
When OpenPets Claude hooks are installed, these Claude Code hook events are registered:
```text
UserPromptSubmit
PreToolUse
PermissionRequest
Notification
Stop
StopFailure
```
Source of truth: `packages/claude/src/hook-settings.ts`.
### Hook event to reaction/message table
| Claude hook event | Condition | Reaction sent | Does it speak? | Possible speech text |
| --- | --- | --- | --- | --- |
| `UserPromptSubmit` | Any user prompt submitted. | `thinking` | Yes, throttled. | `Thinking it through`, `Let me check`, `On it`, `Working it out` |
| `PreToolUse` | Tool is `Edit`, `Write`, or `MultiEdit`. | `editing` | No. | — |
| `PreToolUse` | Tool is `Bash` and command looks like a test command. | `testing` | No. | — |
| `PreToolUse` | Tool is `Bash` and command does not look like a test command. | `running` | No. | — |
| `PreToolUse` | Any other tool. | `working` | No. | — |
| `PermissionRequest` | Claude asks for permission/approval. | `waiting` | Yes, shorter cooldown. | `Approval needed` |
| `Notification` | Claude sends a notification. | `waving` | No. | — |
| `Stop` | Claude completes normally. | `success` | Yes, throttled. | `Done`, `That worked`, `All set`, `Nice, finished` |
| `StopFailure` | Claude run fails/stops with failure. | `error` | Yes, throttled. | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` |
| Unknown event | Hook event name exists but is not recognized. | None. | No. | — |
| Invalid/missing event | Payload is invalid or has no event name. | None. | No. | — |
Source of truth: `packages/claude/src/hooks.ts` and `packages/claude/src/hook-messages.ts`.
### Bash test detection
For `PreToolUse` with `tool_name: "Bash"`, OpenPets classifies the command as `testing` if the command contains one of these patterns:
```text
test
vitest
jest
pytest
npm test
pnpm test
yarn test
cargo test
go test
```
Otherwise Bash maps to `running`.
## Hook speech throttling
Hooks do not speak on every matching event. Speech is throttled so the pet does not spam bubbles.
| Speech category | Events that use it | Cooldown |
| --- | --- | --- |
| `thinking` | `UserPromptSubmit` | 20 seconds |
| `success` | `Stop` | 20 seconds |
| `error` | `StopFailure` | 20 seconds |
| `permission` | `PermissionRequest` | 3 seconds |
If speech is throttled, OpenPets still sends the reaction with `pet.react`. Example: if `Stop` happens twice within 20 seconds, the second one sends `success` as a reaction only instead of saying another success phrase.
Source of truth: `packages/claude/src/hooks.ts` (`speechCooldownMs`, `permissionCooldownMs`, `shouldSendSpeech`).
## OpenCode plugin event mapping
When OpenPets OpenCode setup is installed, OpenCode loads the `@open-pets/opencode` plugin. Plugin hooks return immediately and schedule OpenPets IPC calls in the background so OpenCode is not blocked by pet activity.
| OpenCode hook/event | Condition | Reaction sent | Does it speak? |
| --- | --- | --- | --- |
| `chat.message` | User/chat activity. | `thinking` | Yes, throttled. |
| `tool.execute.before` | Edit/write/patch-like tool name. | `editing` | No. |
| `tool.execute.before` | Shell/bash-like tool name and test-like command category. | `testing` | No. |
| `tool.execute.before` | Shell/bash-like tool name, not test-like. | `running` | No. |
| `tool.execute.before` | Other tools. | `working` | No. |
| `event` | Bus event `permission.asked`. | `waiting` | Yes, short approval-needed speech. |
| Internal/self OpenPets tools | OpenPets MCP/status/say/react calls. | None. | No. |
OpenCode speech uses the same safe shared message pools as Claude hooks. It never includes prompt text, commands, output, code, logs, paths, URLs, or secrets.
Source of truth: `packages/opencode/src/opencode-plugin-runtime.ts` and `packages/agent-events/src/index.ts`.
## Hook speech safety rules
Hook-generated speech must be safe before it is sent. It must be:
- 1140 characters.
- Single-line.
- Not code-like.
- Not a URL.
- Not a path.
- Not secret-like.
If a generated speech message fails validation, the hook ignores the error and does not break Claude Code.
Source of truth: `packages/claude/src/hooks.ts`, `packages/opencode/src/opencode-plugin-runtime.ts`, and `packages/agent-events/src/index.ts`.
## MCP tool mapping
The public MCP tools are intentionally small:
| MCP tool | Input | Effect |
| --- | --- | --- |
| `openpets_status` | none | Checks whether OpenPets is running and which pet is targeted. Does not change pet state. |
| `openpets_react` | `{ reaction }` | Drives the reaction animation and may show a short randomized temporary bubble from that reaction's message pool. |
| `openpets_say` | `{ message, reaction? }` | Sends a message bubble, optionally with a reaction. The message is what appears in the bubble. |
### MCP message validation
`openpets_say` messages must be:
- 1140 characters after trimming.
- Single-line.
- Not code-like.
- Not a URL.
- Not path-like.
- Not secret-like.
Source of truth: `packages/mcp/src/tools.ts` and `apps/desktop/src/local-ipc-protocol.ts`.
## Target pet routing
OpenPets decides which pet gets the reaction/message as follows:
| Situation | Target |
| --- | --- |
| No selected/configured pet. | Desktop default pet. |
| Selected/configured pet is the built-in pet or current default pet. | Desktop default pet. |
| Selected/configured pet is installed and not broken. | Explicit agent pet window for that pet. |
| Selected/configured pet is invalid, missing, or broken. | Falls back to desktop default pet. |
Claude hooks, OpenCode plugin events, and MCP use a short-lived lease when a project/configured pet is specified. That lease routes `say`/`react` to the explicit pet window.
Source of truth: `apps/desktop/src/local-ipc.ts` (`resolveLeaseTarget`), `packages/claude/src/hooks.ts` (`acquireHookLease`), and `packages/opencode/src/opencode-plugin-runtime.ts`.
## Paused behavior
If the default pet is paused:
- Default-pet reactions/messages are not shown and return `reason: "paused"`.
- Explicit agent pet reactions/messages also check the default paused state and are not shown when paused.
Source of truth: `apps/desktop/src/default-pet-controller.ts` and `apps/desktop/src/local-ipc.ts`.
## Visibility behavior
Default pet:
- A reaction/message refreshes the default pet content.
- The default pet is only shown for external events if it is already visible or `openDefaultPetOnLaunch` is enabled.
Explicit agent pet:
- A reaction/message opens/shows that agent pet window.
- The agent pet window is closed when its explicit lease expires and no explicit lease remains.
Source of truth: `apps/desktop/src/default-pet-controller.ts`, `apps/desktop/src/agent-pet-controller.ts`, and `apps/desktop/src/local-ipc.ts`.
## Current product implications
1. Hook emotions now change sprite animation rows; bubbles are still used for short visible text.
2. Only some hooks/events make the pet speak. Claude prompt/permission/stop events and OpenCode chat/permission events can produce speech. Tool-use hooks only react.
3. Speech is intentionally short and safe. Hooks/plugins never display prompts, command output, code, logs, paths, URLs, or secrets.
4. Messages override reaction text visually. If a message is present, the bubble shows the message, not a randomized reaction line, while the reaction still controls animation.
5. `celebrating` exists as an allowed manual reaction but is not emitted by Claude hooks today.

199
docs/mvp-validation.md Normal file
View file

@ -0,0 +1,199 @@
# OpenPets MVP validation
This is the authoritative local MVP closeout checklist for OpenPets 2.0.
The current checkpoint is a macOS local dogfooding package built with `electron-builder --dir`. It is not a public distribution release.
## MVP readiness summary
| Area | Status | Notes |
| --- | --- | --- |
| Tray/menu bar app | Ready for macOS local validation | Validate from the packaged app, not only `pnpm dev:desktop`. |
| Default pet | Ready for macOS local validation | Confirm show/hide, pause/resume, dragging, and restart persistence basics. |
| Default pet animation | Ready for macOS local validation | Confirm bundled sprite animation, left/right drag direction changes, and idle settle. |
| First-run onboarding | Ready for macOS local validation | Can be retested by removing OpenPets app data. |
| Pet Manager | Ready for macOS local validation | Catalog availability may affect install verification. |
| Settings persistence | Ready for macOS local validation | Toggle a preference, restart, and confirm persistence. |
| Local IPC/client smoke | Ready for macOS local validation | Run while the packaged app is open. |
| Claude Agent Setup preview | Ready for macOS local validation | Preview should use packaged bundled `node .../app.asar.unpacked/...` paths. |
| Claude config mutation | Optional/manual only | Requires user confirmation, backups, and cleanup/removal when done. |
| Windows/Linux packages | Unverified | Documented gap until tested on those platforms. |
| Signing/notarization/installers/auto-update | Deferred | Required before public distribution. |
## Artifact identity
| Field | Value |
| --- | --- |
| Workspace package version | `2.0.0` |
| Desktop package version | `2.0.0` |
| Local package command | `pnpm package:desktop:dir` |
| Package output | `apps/desktop/dist-electron/` |
| macOS app output | `apps/desktop/dist-electron/mac-arm64/OpenPets.app` on Apple Silicon |
| Manual tester/date/result | Fill in during validation |
## Prerequisites
- Node.js `>=20`.
- `pnpm@11.0.8` from the workspace root.
- A workspace checkout for developer validation commands.
- macOS for the current local package validation flow.
- `node` available on Claude's PATH if you opt into packaged Claude MCP/hooks validation from Claude itself.
## Automated validation commands
Run from the workspace root in this order:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Run `pnpm package:desktop:dir` last because desktop package tests exercise the cleanup helper and may remove `apps/desktop/dist-electron`.
The package script also cleans `apps/desktop/dist-electron` before packaging to avoid stale output masking regressions.
## Package behavior notes
Phase 10C enables ASAR packaging for Electron app contents and unpacks `node_modules/**` so Claude can execute bundled MCP/hook commands as regular files with external `node`. ASAR is packaging hygiene, not a security boundary.
The builder config sets `npmRebuild: false` because the current desktop runtime dependencies are JavaScript-only. Revisit that setting if native modules are added.
The macOS package uses an OpenPets MVP `.icns` derived from the current 64×64 tray icon. It may look soft at large sizes. Higher-resolution branding and Windows/Linux icon assets remain future polish.
## Platform matrix
| Platform | Target | Status | Notes |
| --- | --- | --- | --- |
| macOS | `electron-builder --dir` unpacked app | Validate locally now | Unsigned local app may require right-click → Open outside the dev machine. |
| Windows | `electron-builder --dir` unpacked app | Unverified | Unsigned apps may trigger Defender/SmartScreen warnings; not part of this MVP closeout. |
| Linux | `electron-builder --dir` unpacked app | Unverified | Desktop integration varies by distro/window manager; not part of this MVP closeout. |
Installer targets, notarization, code signing, auto-update, and public distribution are deferred.
## macOS manual packaged-app checklist
Run this checklist after the automated commands pass.
1. Launch the generated packaged app from `apps/desktop/dist-electron/`.
- Expected: OpenPets starts without using the dev Electron entrypoint.
2. Confirm the tray/menu bar icon appears.
- Expected: Tray/menu items are available, including Manage Pets, Configure Agents, Settings, show/hide, pause/resume, and Quit.
3. Confirm the default pet behavior.
- Expected: The default pet appears as an animated bundled sprite, can be shown/hidden, paused/resumed, dragged, and still behaves correctly after app restart.
- Expected: Dragging right switches to a right-facing/running animation; dragging left switches to a left-facing/running animation; stopping settles back to idle without jitter.
4. Complete onboarding if it appears.
- Expected: Onboarding can be completed or skipped according to the UI, and does not block using the built-in default pet.
- To retest first-run onboarding, remove the OpenPets app data directory for your platform before launching again.
5. Open Pet Manager.
- Expected: Installed pets and catalog state render. If the catalog is unavailable, record the catalog error and mark catalog install unverified for that run.
6. If a catalog pet is available, install it and set it as default.
- Expected: Install succeeds, the pet can become default, and the app remains usable after restart.
- Expected: The installed default pet shows one animated frame sequence, not the full square spritesheet, and drag left/right changes its animation direction.
- Expected: Pet Manager keeps the current default card actionable; removable default pets can still be removed safely.
7. Open Settings, toggle a preference, quit, relaunch, and confirm persistence.
- Expected: The changed preference persists across restart.
8. While the packaged app is running, run the local IPC smoke command from the workspace root:
```bash
pnpm --filter @open-pets/client smoke:status
```
- Expected: The client discovers the running packaged app and receives status without token/version errors.
9. Open Agent Setup and inspect the Claude preview without applying changes.
- Expected: Packaged previews use `node` commands pointing inside the packaged app, not unpublished `npx -y @open-pets/*` commands.
- Expected MCP shape: `node .../app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js`.
- Expected hooks shape: `node .../app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed`.
10. Optional mutating Claude validation: configure/install Claude MCP/hooks only if you intentionally want to modify your real Claude settings.
- Expected: OpenPets shows what it will change and creates backups before editing.
- Cleanup: use Agent Setup remove/uninstall actions when done if you do not want to keep the integration.
11. Quit OpenPets from the tray.
- Expected: The app exits cleanly and removes transient local IPC discovery state.
## Pet and chat bubble quality checks
While OpenPets is running, trigger a speech bubble from the workspace root:
```bash
pnpm --filter @open-pets/client smoke:say "Working on it" thinking
```
Expected: the bubble is compact, readable, visually attached to the pet, and does not look like an oversized wireframe card.
Trigger a reaction:
```bash
pnpm --filter @open-pets/client smoke:react success
```
Expected: the pet remains animated and the bubble/reaction presentation stays readable and short.
## Claude packaged command notes
Packaged Claude integration uses bundled commands inside the packaged OpenPets app instead of unpublished package names.
Expected command resources live under `app.asar.unpacked`, not `app.asar`, because Claude launches them through external `node`:
```text
.../OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js
.../OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js
```
Packaged bundled Claude commands require `node` to be available on Claude's PATH.
Claude settings may contain absolute paths into the packaged OpenPets app. Moving, deleting, or replacing the app may require opening Agent Setup and using Replace/Install again. Use Agent Setup remove/uninstall before deleting the app if you want to remove OpenPets-managed Claude entries.
## Safe vs mutating validation
Safe validation:
- Running workspace tests/checks/package commands.
- Launching the packaged app.
- Inspecting Agent Setup previews.
- Running `pnpm --filter @open-pets/client smoke:status` against the local packaged app.
Mutating validation:
- Applying Claude MCP configuration.
- Installing Claude hooks.
- Removing/uninstalling Claude integration.
- Deleting OpenPets app data to retest onboarding.
Only run mutating steps intentionally. Automated tests must use temp fixtures and must not edit real Claude settings or real OpenPets user data.
## Cleanup and data-loss notes
OpenPets stores normal app state in Electron's per-user app data directory. The app logs the exact state file path on startup:
```text
OpenPets state initialized at <path>/openpets-state.json.
```
Deleting that app data directory removes installed pets, default-pet selection, onboarding completion, and preferences for that platform user.
Claude configuration is separate from OpenPets app data. Uninstalling or deleting OpenPets does not automatically remove Claude MCP entries or Claude hooks.
Package output can be removed safely:
```text
apps/desktop/dist-electron/
```
## Unsigned local-app warnings
The current package output is unsigned and not notarized.
- macOS may warn that the app is from an unidentified developer when moved to another machine.
- Windows may show Defender/SmartScreen prompts.
- Linux behavior depends on distro and desktop environment.
These warnings are expected for local MVP validation and are not acceptable for public distribution.
## Known limitations and post-MVP follow-ups
- Public distribution requires signing, notarization where applicable, installer targets, and an update strategy.
- Windows and Linux package behavior need real platform validation.
- Windows/Linux app icon assets are not configured yet.
- The current macOS app icon is MVP-quality and generated from a 64×64 source.
- Published `npx -y @open-pets/*` package flows remain future distribution work; local packaged Claude setup uses bundled app paths.
- `asarUnpack: node_modules/**` is broad for MVP reliability and can be narrowed after packaged command needs are fully characterized.

287
docs/opencode.md Normal file
View file

@ -0,0 +1,287 @@
# OpenCode integration
This document describes how OpenPets Desktop integrates with OpenCode, what files are configured, how OpenCode reaches the desktop app, and which OpenCode events trigger pet reactions or speech.
## Integration surfaces
OpenCode support has three related surfaces:
1. **MCP tools** — OpenCode gets the `openpets_status`, `openpets_react`, and `openpets_say` tools through `@open-pets/cli mcp`.
2. **OpenCode instructions** — OpenCode loads a managed `openpets.md` instruction file telling agents when to use the tools.
3. **OpenCode plugin hooks**`@open-pets/opencode` turns OpenCode activity into automatic pet reactions and short safe speech.
The desktop setup UI for OpenCode is implemented in `apps/desktop/src/agent-setup.ts`. It previews, installs, doctors, and removes global OpenCode setup through `@open-pets/opencode` helpers.
This document covers **Desktop global setup** first. The CLI also supports **project-local OpenCode setup**; see [Project-local OpenCode setup](#project-local-opencode-setup).
## Global config location
OpenPets uses OpenCode's global config directory:
```text
OPENCODE_CONFIG_DIR, if set
Windows: %APPDATA%/opencode
macOS/Linux: $XDG_CONFIG_HOME/opencode, or ~/.config/opencode fallback
```
Inside that directory OpenPets considers these config files, in order:
```text
config.json
opencode.json
opencode.jsonc
```
If no file exists, the default creation target is:
```text
opencode.jsonc
```
OpenPets also writes a global instruction file:
```text
<opencode-config-dir>/openpets.md
```
## Files and config managed by OpenPets
OpenCode global setup writes or updates three config fields.
### MCP entry
Published setup creates an MCP entry like:
```jsonc
{
"mcp": {
"openpets": {
"type": "local",
"command": ["npx", "-y", "@open-pets/cli@<version>", "mcp"],
"enabled": true
}
}
}
```
If a pet is selected, the command includes it:
```jsonc
"command": ["npx", "-y", "@open-pets/cli@<version>", "mcp", "--pet", "<petId>"]
```
In local development or packaged desktop mode, OpenPets can use:
```jsonc
"command": ["node", "<local-or-bundled-cli-entry>", "mcp", ...]
```
Packaged and local `node <entry>` commands require `node` to be available on OpenCode's `PATH`. Desktop setup reports an error instead of installing a bundled command if `node --version` cannot run from the agent environment.
### Instructions
OpenPets adds the managed instruction file to OpenCode's `instructions` array:
```jsonc
{
"instructions": ["<opencode-config-dir>/openpets.md"]
}
```
That file contains:
```md
<!-- OPENPETS:START -->
## OpenPets
OpenPets MCP tools may be available.
Use OpenPets as a short visible status channel for meaningful coding progress:
- Use `openpets_say` when starting, completing, blocking, or needing review on non-trivial work.
- Keep messages brief, user-facing, and non-sensitive.
- Do not include code, logs, secrets, URLs, or file paths.
- Use `openpets_react` for small visual or emotional feedback.
- Use `openpets_status` only when checking availability or the targeted pet.
- Do not spam every internal step.
<!-- OPENPETS:END -->
```
### Plugin
OpenPets adds the OpenCode plugin:
```jsonc
{
"plugin": ["@open-pets/opencode@<version>"]
}
```
If a pet is selected, the plugin entry includes options:
```jsonc
{
"plugin": [["@open-pets/opencode@<version>", { "pet": "<petId>" }]]
}
```
The plugin id is:
```text
open-pets-opencode
```
## Setup behavior
OpenPets global setup is conservative:
- It parses OpenCode config as JSONC, not strict JSON.
- It preserves unrelated user config.
- It avoids unsafe symlinks and oversized files.
- It writes temp files and backups before replacing existing files.
- It refuses to overwrite custom OpenPets-like config entries that it cannot prove are managed by OpenPets.
- It cleans up stale managed entries from any non-selected global config file when one selected config file becomes the owner.
OpenPets chooses one global config file as the owner for the managed MCP, instructions, and plugin entries. It first prefers the effective owner of relevant array fields such as `plugin` or `instructions`, then reuses an existing managed OpenPets owner, then uses the highest-precedence existing global config file, and finally creates `opencode.jsonc` if none exists. Setup refuses ambiguous cases, such as plugin and instruction arrays living in different files, managed OpenPets entries appearing in multiple global files, or higher-precedence arrays shadowing lower-precedence user entries.
Removal deletes only OpenPets-managed MCP, instructions, and plugin entries. It leaves unrelated OpenCode config intact.
## Project-local OpenCode setup
The `@open-pets/cli` package can configure OpenCode inside a project:
```sh
openpets configure --agent opencode --pet <petId>
```
Project-local setup writes inside the current project directory:
```text
.opencode/opencode.jsonc
.opencode/openpets.md
```
If another supported OpenCode project config already exists, OpenPets updates that file instead of creating `.opencode/opencode.jsonc`. Project-local setup adds the same three kinds of entries as global setup:
- `mcp.openpets` using `@open-pets/cli mcp --pet <petId>`.
- `instructions` pointing to `.opencode/openpets.md`.
- `plugin` using `@open-pets/opencode` with `{ "pet": "<petId>" }`.
Project-local config and instructions can be committed to the repository and include the selected pet id. That is useful for shared project identity, but users should review those files before committing.
## Runtime path
When OpenCode calls an OpenPets MCP tool or when the OpenCode plugin reacts to an event, the runtime path is:
```text
OpenCode
-> @open-pets/cli mcp or @open-pets/opencode plugin
-> @open-pets/client
-> OpenPets desktop local IPC discovery file
-> OpenPets desktop IPC socket/pipe
-> default pet controller or explicit agent pet controller
```
The desktop app writes a discovery file such as:
```text
macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json
Windows: %APPDATA%/OpenPets/runtime/ipc.json
Linux: $XDG_RUNTIME_DIR/openpets/ipc.json, or ~/.config/OpenPets/runtime/ipc.json fallback
```
That file contains the IPC endpoint and a per-run token. Clients must send that token with every request.
## MCP tools
OpenCode sees these tools when the MCP entry is enabled:
| Tool | Purpose | Desktop IPC method |
| --- | --- | --- |
| `openpets_status` | Check whether OpenPets is reachable and which pet is targeted. | `status` |
| `openpets_react` | Set a short reaction on the target pet. | `pet.react` |
| `openpets_say` | Show a short safe speech bubble, optionally with a reaction. | `pet.say` |
Depending on OpenCode's MCP naming, tool names may appear with a server prefix, for example `openpets_openpets_say`. The OpenCode plugin explicitly ignores OpenPets tool calls so the pet does not react to its own status messages.
`openpets_say` is validated before it reaches the desktop app. Messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths.
## Pet targeting
If the MCP command or plugin was configured without a pet, events target the desktop default pet.
If configured with a pet, OpenPets asks the desktop app for a lease:
```text
lease.acquire({ requestedPetId: "<petId>" })
```
Routing rules:
- Missing `pet` option -> default pet.
- Requested pet is the default pet or built-in pet -> default pet.
- Invalid, missing, or broken pet -> default pet with a fallback reason.
- Valid installed non-default pet -> explicit agent pet window.
Explicit leases expire after a short TTL unless refreshed. The plugin caches and reuses a lease while it is still valid. When the last explicit lease for a pet expires or is released, OpenPets clears and closes that agent pet window.
## OpenCode plugin reaction and speech mapping
The OpenCode plugin is decorative and best-effort. It schedules OpenPets work in the background and returns immediately so it does not slow down OpenCode hooks. If OpenPets is closed or IPC is unavailable, the plugin swallows the error.
| OpenCode event/hook | Trigger condition | Reaction | Speech bubble |
| --- | --- | --- | --- |
| `chat.message` | OpenCode receives a chat message. | `thinking`, throttled. | None. |
| `tool.execute.before` | Tool name contains `edit`, `write`, `patch`, or `apply_patch`. | `editing` | None. |
| `tool.execute.before` | Tool name contains `bash`, `shell`, or `terminal`, and command args look test-like. | `testing` | None. |
| `tool.execute.before` | Tool name contains `bash`, `shell`, or `terminal`, and command args do not look test-like. | None. | None. |
| `tool.execute.before` | Any other non-OpenPets tool. | None. | None. |
| `event` | Bus event type is `permission.asked` for a non-OpenPets tool. | `waiting` | `Approval needed`, throttled with a short cooldown. |
| `event` | Bus event type is `session.error`. | `error` | One error-pool message, throttled. |
| `event` | Bus event type is `session.status` and status type is `idle`. | `success`, throttled. | None. |
| `tool.execute.after` | Any tool completed. | None | None. |
| OpenPets MCP tool | Tool name is `openpets_status`, `openpets_say`, `openpets_react`, or server-prefixed equivalent. | None | None. |
| Unknown event | Event is not recognized. | None | None. |
Test-like command detection is intentionally coarse and private. It may inspect only a bounded command string to classify the reaction. The command text is never sent to speech.
OpenCode hooks do **not** use the left/right walking animations or the `running` reaction. The `running-right` and `running-left` sprite rows are reserved for pet drag/move motion, and generic shell activity is silent by default. Hook reactions use quieter phase changes instead: editing maps to the generic active-work row, thinking maps to `review`, waiting/testing maps to `waiting`, success maps to `jumping`, and errors map to `failed`.
## Plugin speech pools
When the plugin is allowed to speak, it chooses one static local message from the matching pool:
| Category | Possible messages |
| --- | --- |
| `thinking` | `Thinking it through`, `Let me check`, `On it`, `Working it out` |
| `success` | `Done`, `That worked`, `All set`, `Nice, finished` |
| `error` | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` |
| `permission` | `Approval needed` |
Speech is throttled by category so hooks do not spam bubbles. Current speech cooldowns are:
| Category | Cooldown |
| --- | --- |
| `permission` | 3 seconds |
| `thinking`, `success`, `error` | 20 seconds each |
Normal thinking and success hooks no longer speak, so their speech pools are retained for shared/static message support but are not used by OpenCode's default quiet plugin policy. Reaction-only hooks are also deduped, with the same reaction suppressed for about 10 seconds.
OpenCode stores throttle state separately from Claude in an OpenCode-specific file, for example:
```text
Windows: %LOCALAPPDATA%/OpenPets/opencode-hook-throttle.json
macOS/Linux: ${XDG_STATE_HOME:-~/.local/state}/openpets/opencode-hook-throttle.json
fallback: os.tmpdir()/openpets-<uid>/opencode-hook-throttle.json
```
Throttle state stores only speech-category and reaction timestamp keys, never prompts, commands, tool input, output, code, logs, or transcripts.
## Safety rules
- Plugin hooks return immediately and never await OpenPets IPC directly.
- Plugin errors are swallowed unless debug logging is enabled.
- Debug logs sanitize paths and secret-looking values.
- Speech is static and local; it does not include model-generated text.
- Tool args and command text are used only for coarse reaction classification.
- The plugin ignores OpenPets MCP tools to avoid feedback loops.
- Managed setup refuses unsafe symlinks, non-regular files, oversized config files, and conflicting custom OpenPets-like config.

View file

@ -0,0 +1,383 @@
# Phase 00: Workspace foundation and DX
## Goal
Create the fresh OpenPets 2.0 workspace foundation with excellent developer experience and Node/npm-compatible runtime behavior.
This phase should establish the project skeleton and basic commands needed for later implementation phases, without building real product features yet.
## Non-goals
This phase does not implement:
- Tray/menu app behavior.
- Floating pet windows.
- Pet rendering.
- Pet catalog browsing or installation.
- Local IPC.
- MCP tools.
- Claude detection/configuration/hooks.
- First-run onboarding.
- Packaging/release builds.
This phase should also avoid large migrations from v1. v1 remains reference material only.
## User-visible/manual outcome
After this phase, the user/developer should be able to install dependencies and run basic workspace checks from a clean v2 workspace.
Expected manual outcome:
```text
I can install dependencies, inspect the v2 workspace structure, and run basic check/build scripts without using Bun.
```
## Acceptance criteria
- A fresh v2 workspace structure exists outside `v1/`.
- v1 remains untouched except as reference material.
- Runtime and published package design is Node/npm/npx-compatible.
- Bun is not required for v2 runtime behavior.
- Development tooling is documented clearly.
- Node baseline is documented.
- Workspace boundaries exclude v1 and web app code from v2 workspace commands.
- Initial packages are protected from accidental publishing while they are skeletons.
- Basic workspace scripts exist for checking/building the initial skeleton.
- Package names align with the agreed `@open-pets/*` organization.
- The initial package/app structure supports later phases without overbuilding empty abstractions.
- The repo clearly distinguishes current v2 code from v1 reference code.
## Proposed files/directories
Create or update:
```text
package.json
pnpm-workspace.yaml
pnpm-lock.yaml
tsconfig.base.json
.gitignore
README.md # if useful for v2 developer commands
apps/
desktop/
package.json
tsconfig.json
src/
main.ts # placeholder or minimal entry, no real tray behavior yet
packages/
client/
package.json
tsconfig.json
src/index.ts
mcp/
package.json
tsconfig.json
src/index.ts
claude/
package.json
tsconfig.json
src/index.ts
cli/
package.json
tsconfig.json
src/index.ts
pet-format/
package.json
tsconfig.json
src/index.ts
```
Important constraint:
- Do not add large placeholder systems just to fill packages.
- If a package would be purely empty and creates more maintenance than value, keep it minimal with only package metadata and a tiny exported placeholder/type needed for build checks.
- Defer `packages/shared` until there is a concrete first use. Avoid creating a dumping-ground package in Phase 00.
## Technical approach
Use pnpm workspaces for development ergonomics.
Recommended baseline:
```text
Node: 20+
Package manager: pnpm via Corepack
Module style: ESM
Build: plain TypeScript `tsc` for initial packages
```
Node 20+ is a conservative baseline with broad ecosystem support. A later phase may raise this if Electron or another dependency requires it.
Important distinction:
```text
Developer package manager: pnpm
Runtime/user interface: Node/npm/npx
```
This means:
- Contributors may run `pnpm install` and `pnpm check` during development.
- Contributors should use Corepack or an explicitly documented pnpm version from the root `packageManager` field.
- Published packages must be usable through normal Node/npm/npx flows.
- `@open-pets/mcp` should eventually support `npx -y @open-pets/mcp`.
- `@open-pets/cli` should eventually support `npx -y @open-pets/cli ...`.
- No v2 package should rely on Bun runtime APIs.
Workspace inclusion rule:
```yaml
packages:
- "apps/*"
- "packages/*"
```
Do not include:
```text
v1/**
web/**
```
The existing `web/` app and `v1/` references must remain outside v2 workspace commands.
Package privacy rules:
- Root `package.json` is `private: true`.
- `apps/desktop` is `private: true`.
- Skeleton packages should be `private: true` during Phase 00 unless there is a concrete reason to make one publishable immediately.
- Public package names may still be reserved in package metadata, but avoid accidental publication of empty packages.
Initial scripts should be simple and predictable, for example:
```json
{
"scripts": {
"check": "pnpm -r check",
"typecheck": "pnpm -r typecheck",
"build": "pnpm -r build"
}
}
```
Each initial package should expose minimal `check`, `typecheck`, and `build` scripts where practical.
Linting is deferred in Phase 00 unless it is trivial to add without expanding scope. TypeScript strictness is the initial code-quality gate.
Use TypeScript project configuration that can scale to multiple packages without making Phase 00 too complex.
Initial TypeScript/build convention:
- Use `type: "module"`.
- Use strict TypeScript settings.
- Use `moduleResolution` appropriate for modern Node ESM.
- Emit to `dist/`.
- Generate declarations for packages where practical.
- Use source maps where practical.
- Avoid bundlers until a later phase requires them.
Recommended package intent:
- `apps/desktop` — Electron desktop app, implemented in later phases.
- `packages/client` — local IPC client, implemented in Phase 05.
- `packages/mcp` — MCP stdio server, implemented in Phase 06.
- `packages/claude` — Claude detection/configuration/hooks package, implemented in Phases 0708.
- `packages/cli` — explicit user-run CLI commands, implemented after core app/client behavior exists.
- `packages/pet-format` — shared pet manifest/catalog validation types, used by pet manager/catalog install phases.
Deferred package:
- `packages/shared` — defer until a concrete shared constant/type exists. Do not create it in Phase 00 by default.
Electron app note:
- `apps/desktop/src/main.ts` is compile-only placeholder code in Phase 00.
- Do not add real tray behavior, renderer framework, Electron window code, or packaging setup in Phase 00.
- Avoid adding Electron/Vite/React dependencies until Phase 01 unless needed for a minimal compile check.
## Risks and tradeoffs
### Risk: too much empty scaffolding
Creating every planned package immediately may produce fake progress and maintenance overhead.
Mitigation:
- Keep package contents minimal.
- Add only enough for workspace checks and future package boundaries.
- Do not implement fake APIs before their phase.
- Defer `packages/shared` until needed.
### Risk: pnpm confusion vs npm/npx requirement
The project may use pnpm for development while users need npm/npx compatibility.
Mitigation:
- Document the distinction clearly.
- Avoid pnpm-specific runtime assumptions.
- Keep package `bin`/publish behavior npm-compatible when those packages become real.
### Risk: v1 assumptions leaking into v2
v1 is Bun-oriented and has known broken agent configuration flows.
Mitigation:
- Do not copy v1 structure blindly.
- Treat v1 as reference only.
- Re-check docs and design before implementing each real feature phase.
### Risk: Phase 00 becomes abstract tooling work
Phase 00 should enable work, not become a tooling project.
Mitigation:
- Keep tooling minimal.
- Avoid CI/release/publishing complexity until later hardening phases.
### Risk: v2 workspace accidentally runs v1 or web commands
The repository contains existing `v1/` and `web/` folders with their own tooling. Accidentally including them in pnpm workspaces would blur boundaries and may pull Bun-era assumptions into v2.
Mitigation:
- `pnpm-workspace.yaml` includes only `apps/*` and `packages/*`.
- `v1/**` and `web/**` remain outside v2 workspace commands.
### Risk: accidental publish of skeleton packages
Early package skeletons should not be published before they are real.
Mitigation:
- Mark root, apps, and skeleton packages `private: true` in Phase 00.
- Revisit publish settings only in the phase where a package is ready for actual npm usage.
## Security/privacy notes
This phase should not introduce network behavior, local IPC, config editing, pet zip extraction, or agent hooks.
Security/privacy expectations for this phase:
- No secrets or credentials are added.
- No telemetry is added.
- No install scripts should perform surprising system changes.
- No package should require postinstall behavior for core development.
- Skeleton packages should be private to prevent accidental empty-package publication.
## Test/check plan
Automated checks:
```bash
pnpm install
pnpm check
pnpm typecheck
pnpm build
```
Expected result:
- Dependency install succeeds.
- TypeScript/workspace checks pass.
- Typecheck passes under the agreed Node/ESM/strict TypeScript settings.
- Initial package builds pass or intentionally no-op with clear scripts.
If package scripts are intentionally minimal, they should still be explicit and understandable.
## Manual verification guide
After implementation, the user should verify:
1. Run dependency install:
```bash
pnpm install
```
2. Run checks:
```bash
pnpm check
```
3. Run typecheck:
```bash
pnpm typecheck
```
4. Run build:
```bash
pnpm build
```
5. Inspect that v2 code lives outside `v1/`:
```text
apps/desktop
packages/client
packages/mcp
packages/claude
packages/cli
packages/pet-format
```
6. Confirm no Bun command is required for v2 checks/builds.
7. Confirm `pnpm-workspace.yaml` does not include `v1/**` or `web/**`.
Manual acceptance question:
```text
Does Phase 00 pass on your machine: dependencies install, checks/typechecks/builds run, and the v2 workspace structure looks right?
```
## Oracle plan review
Oracle reviewed the Phase 00 plan and approved it with required minor revisions before implementation.
Summary of Oracle feedback:
- Tighten workspace boundary so v2 workspace excludes `v1/**` and `web/**`.
- Define Node/tooling baseline.
- Protect skeleton packages from accidental publishing.
- Clarify scripts and build conventions.
- Avoid adding Electron implementation/dependencies too early.
- Defer `packages/shared` unless immediately needed.
## Oracle feedback disposition
- Fixed: Added workspace inclusion/exclusion rule for `apps/*` and `packages/*`, excluding `v1/**` and `web/**`.
- Fixed: Added Node 20+, Corepack/pnpm, ESM, and plain TypeScript build baseline.
- Fixed: Added privacy rules for root/app/skeleton packages.
- Fixed: Added `typecheck` script expectation and deferred linting explicitly.
- Fixed: Added TypeScript/build convention notes.
- Fixed: Clarified `apps/desktop/src/main.ts` is compile-only placeholder and no tray/Electron implementation belongs in Phase 00.
- Fixed: Deferred `packages/shared` until concrete need exists.
## Oracle implementation review
Oracle reviewed the implemented Phase 00 diff after successful validation with:
```bash
pnpm install && pnpm check && pnpm typecheck && pnpm build
```
Oracle found no blocking correctness, security, or spec issues and approved Phase 00 for manual user verification.
Implementation review disposition:
- Fixed: Updated stale `docs/workflow.md` current-action wording so the workflow document remains reusable across phases.
- Deferred: README Corepack guidance can be expanded later if needed; root `packageManager` already pins pnpm.
- Deferred: Package script/tsconfig simplifications can be revisited if they become maintenance overhead.

View file

@ -0,0 +1,334 @@
# Phase 01: Tray-first desktop shell
## Goal
Create the first runnable OpenPets desktop app shell as a tray/menu-bar-first Electron app.
This phase should prove the desktop process, tray icon/menu, placeholder task windows, and clean quit flow work before building pet rendering or integrations.
## Non-goals
This phase does not implement:
- Floating pet windows.
- Transparent/always-on-top pet rendering.
- Pet dragging or persisted pet position.
- Real Pet Manager catalog behavior.
- Real Agent Setup detection/configuration.
- Real Settings persistence.
- Local IPC.
- MCP integration.
- Claude hooks.
- First-run onboarding.
- Production packaging/signing/notarization.
## User-visible/manual outcome
After this phase, the user/developer should be able to run the desktop app and confirm:
```text
OpenPets launches as a tray/menu-bar app, exposes the agreed tray menu, opens placeholder windows, and quits cleanly.
```
## Acceptance criteria
- Desktop app can be launched with a documented development command.
- App creates a tray/menu-bar icon.
- App does not open a dashboard/main window automatically on startup.
- Tray menu contains the agreed initial entries:
```text
OpenPets
────────────────
Default Pet: <name>
Show Default Pet / Hide Default Pet
Pause All Pets / Resume All Pets
────────────────
Manage Pets...
Configure Agents...
Settings...
────────────────
Quit OpenPets
```
- `OpenPets` is a disabled title row.
- `Default Pet: <name>` exists and can open the placeholder Pet Manager window.
- `Show Default Pet / Hide Default Pet` exists but may be disabled or no-op until Phase 02.
- `Pause All Pets / Resume All Pets` exists and toggles visible menu state, even if no pets exist yet.
- `Manage Pets...` opens a placeholder Pet Manager window.
- `Configure Agents...` opens a placeholder Agent Setup window.
- `Settings...` opens a placeholder Settings window.
- Placeholder windows are single-instance: choosing the same menu item focuses/reopens the existing window instead of spawning duplicates.
- Closing a placeholder window destroys only that window, clears its window reference, and does not quit the app.
- The app process is single-instance: a second launch must not create duplicate tray icons.
- `Quit OpenPets` exits the app cleanly.
- macOS behavior is handled intentionally: closing placeholder windows does not quit; explicit quit does quit.
- Basic app lifecycle errors are logged clearly during development.
- Existing Phase 00 checks continue to pass.
## Proposed files/directories
Update:
```text
apps/desktop/package.json
apps/desktop/tsconfig.json
apps/desktop/src/main.ts
README.md
```
Likely add:
```text
apps/desktop/src/tray.ts
apps/desktop/src/windows.ts
apps/desktop/src/state.ts
apps/desktop/src/lifecycle.ts
apps/desktop/src/assets.ts
```
Exact file names can change if a simpler structure is better, but Phase 01 should keep the app shell understandable.
## Technical approach
Use Electron for the desktop shell.
Recommended Phase 01 behavior:
- Add Electron as a development/runtime dependency of `apps/desktop` only.
- Add a dev command such as `pnpm --filter @open-pets/desktop dev`.
- Keep renderer placeholders minimal; do not introduce a frontend framework yet unless required.
- Use simple BrowserWindow placeholder windows with hardcoded data URLs for Phase 01.
- Keep all real product logic out of placeholders.
- Use a simple generated/nativeImage tray icon suitable for development.
- Keep all windows task-specific; do not create a dashboard.
Recommended initial desktop scripts:
```json
{
"scripts": {
"dev": "pnpm build && electron .",
"check": "pnpm typecheck",
"typecheck": "tsc --noEmit",
"build": "tsc"
}
}
```
Electron launch approach should be simple for Phase 01:
- Compile TypeScript to `dist/`.
- Start Electron against `dist/main.js`.
- Avoid production packager setup until Phase 10.
Because Phase 01 uses `tsc` only, avoid static asset copy complexity:
- Placeholder window HTML should be hardcoded data URLs.
- Tray icon should be generated in code through Electron/nativeImage or otherwise not require copied runtime assets.
- Do not place required runtime HTML/icons under `src/` unless the implementation also includes an explicit copy strategy.
Electron lifecycle rules:
- `app.whenReady()` creates the tray only.
- No window opens on startup.
- `window-all-closed` does not quit the app.
- macOS `activate` does not create a dashboard/main window.
- `Quit OpenPets` sets an intentional quit flag and calls `app.quit()`.
- Use `app.requestSingleInstanceLock()` so a second launch does not create duplicate tray icons.
- On second instance, keep/focus the existing app where practical and exit the second process.
- Set the app name to `OpenPets` where practical.
macOS tray/Dock decision:
- Call `app.dock.hide()` on macOS for the tray/menu-bar-first shell.
- Do not show a Dock icon in normal Phase 01 operation.
- If this causes practical development issues, document them as known limitations instead of changing the product direction silently.
Tray/menu state:
- Default pet label can use a hardcoded placeholder name such as `Default Pet: Built-in Pet` or `Default Pet: Snoopy` until real pet state exists.
- Use `Default Pet: Built-in Pet` for Phase 01.
- `Default Pet: Built-in Pet` opens the placeholder Pet Manager window.
- `Show Default Pet` is disabled until Phase 02.
- Pause state can be an in-memory boolean for now.
- Pause toggle only changes the in-memory menu label between `Pause All Pets` and `Resume All Pets`.
Placeholder windows:
- Pet Manager placeholder should make clear it is placeholder UI.
- Agent Setup placeholder should make clear it is placeholder UI.
- Settings placeholder should make clear it is placeholder UI.
- Each placeholder window should have a stable title.
- Opening a placeholder should show/restore/focus the existing window if it already exists.
- Closing a placeholder should destroy it and set its reference to `null`.
## Risks and tradeoffs
### Risk: adding frontend framework too early
Phase 01 only needs placeholder windows. Adding Vue/React/etc. now could slow the shell checkpoint.
Mitigation:
- Use minimal static placeholder UI in Phase 01.
- Decide renderer framework later when real UI work begins.
### Risk: platform tray differences
Tray/menu behavior differs between macOS, Windows, and Linux.
Mitigation:
- Implement the simplest Electron-supported tray/menu behavior first.
- Document known platform limitations from manual testing.
- Do not attempt packaging or advanced native integration in Phase 01.
### Risk: app accidentally becomes dashboard-first
Opening a main window automatically would conflict with the agreed product surface.
Mitigation:
- Do not open any main/dashboard window on startup.
- Only tray and explicitly requested task windows exist.
### Risk: app process quits unexpectedly when windows close
Electron defaults can differ by platform and window lifecycle.
Mitigation:
- Manage app quit explicitly.
- Closing placeholder task windows should not quit the app.
- `Quit OpenPets` should be the clear exit path.
### Risk: static assets fail at runtime
Running Electron from `dist/main.js` while assets live under `src/` can break if assets are not copied.
Mitigation:
- Use data URLs for placeholder HTML in Phase 01.
- Generate the development tray icon in code or use an asset strategy that does not depend on unconfigured copies.
## Security/privacy notes
This phase should not touch user agent configuration, shell profiles, Claude settings, local IPC sockets, network, pet zips, or user data beyond normal Electron app startup.
Security/privacy expectations:
- No telemetry.
- No network calls.
- No config file edits.
- No auto-start/login item changes.
- No shell command execution beyond documented development scripts.
- Placeholder windows should not enable risky web content behavior unnecessarily.
Required BrowserWindow safety defaults for placeholders:
- `nodeIntegration: false`.
- `contextIsolation: true`.
- `sandbox: true`.
- No preload script unless absolutely needed. Phase 01 should not need one.
- No remote content loaded.
- Deny navigation away from the placeholder content.
- Deny new-window creation.
- If using data URLs, all content must be hardcoded and no user data should be interpolated.
Placeholder HTML should include a restrictive content security policy where practical.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Manual app run command should be documented after implementation, likely:
```bash
pnpm --filter @open-pets/desktop dev
```
Expected automated result:
- TypeScript checks pass.
- Desktop app build passes.
- Existing workspace package checks continue to pass.
## Manual verification guide
After implementation, the user should verify:
1. Start the desktop app with the documented command.
2. Confirm no dashboard/main window opens automatically.
3. Confirm OpenPets appears in the tray/menu bar.
4. Open the tray menu and verify entries match the Phase 01 menu.
5. Click `Manage Pets...` and confirm one placeholder Pet Manager window opens.
6. Click `Manage Pets...` again and confirm it focuses/reuses the existing window instead of spawning duplicates.
7. Repeat for `Configure Agents...`.
8. Repeat for `Settings...`.
9. Toggle `Pause All Pets` and confirm the menu changes to `Resume All Pets`.
10. Close placeholder windows and confirm the app stays running in tray/menu bar.
11. Choose `Quit OpenPets` and confirm the app exits cleanly.
Manual acceptance question:
```text
Does Phase 01 pass on your machine: tray app launches, menu matches, placeholder windows work, and quit is clean?
```
## Oracle plan review
Oracle reviewed the initial Phase 01 spec and blocked implementation until the spec was tightened.
Summary of required Oracle feedback:
- Define Electron lifecycle behavior explicitly.
- Add app-level single-instance behavior.
- Specify asset strategy so `tsc` builds do not break runtime HTML/icons.
- Make BrowserWindow security defaults mandatory.
- Choose placeholder window close semantics.
- Choose menu placeholder states.
- Specify exact desktop scripts.
- Add macOS tray/Dock behavior.
## Oracle feedback disposition
- Fixed: Added explicit Electron lifecycle rules.
- Fixed: Added `app.requestSingleInstanceLock()` requirement.
- Fixed: Chose data URLs/generated icon strategy to avoid asset-copy issues in Phase 01.
- Fixed: Made BrowserWindow security defaults mandatory.
- Fixed: Chose destroy-on-close semantics for placeholder windows.
- Fixed: Chose `Default Pet: Built-in Pet`, disabled `Show Default Pet`, and in-memory pause-label toggle for Phase 01.
- Fixed: Added exact `dev` script shape: `pnpm build && electron .`.
- Fixed: Added macOS `app.dock.hide()` decision.
## Oracle implementation review
Oracle reviewed the implemented Phase 01 diff after successful validation with:
```bash
pnpm check && pnpm typecheck && pnpm build && pnpm --filter @open-pets/desktop exec electron --version
```
Initial implementation review blocked manual verification until two issues were fixed:
- Tray icon used an SVG data URL that may not be consistently supported by Electron tray icons.
- Placeholder `window.loadURL(...)` rejection was not handled explicitly.
Implementation review disposition:
- Fixed: Replaced SVG tray icon with generated raw bitmap data through `nativeImage.createFromBitmap(...)`.
- Fixed: Added `image.isEmpty()` logging for tray icon creation failures.
- Fixed: Added explicit `loadURL(...).catch(...)` logging.
- Fixed: Attached `ready-to-show` before `loadURL(...)`.
- Fixed: Added `render-process-gone` logging.
- Fixed: Hardened placeholder CSP with `base-uri 'none'`, `form-action 'none'`, and `frame-src 'none'`.
- Fixed: Updated README wording for `apps/desktop`.
Oracle re-reviewed the fixes and approved Phase 01 for manual user verification.

View file

@ -0,0 +1,420 @@
# Phase 02: Default pet window foundation
## Goal
Show the bundled default pet as a real floating desktop companion window.
This phase should prove the core visible OpenPets experience: a small pet appears, can be dragged, can be shown/hidden from the tray, can be paused/resumed, and remembers its position across app restarts.
## Non-goals
This phase does not implement:
- Pet catalog browsing or installation.
- Multiple installed pets.
- Non-default temporary agent pets.
- MCP leases.
- Local IPC.
- Claude integration.
- Speech bubbles.
- Complex physics.
- Advanced desktop-edge/running behavior.
- Complex multi-monitor behavior beyond safe positioning.
- First-run onboarding.
- Production packaging.
## User-visible/manual outcome
After this phase, the user/developer should be able to run the desktop app and confirm:
```text
The bundled default pet appears as a small floating window, can be dragged, can be hidden/shown from the tray, pause/resume visibly affects it, and its position persists across restarts.
```
## Acceptance criteria
- Desktop app still launches tray-first.
- Bundled default pet appears automatically on app launch.
- Pet window is frameless.
- Pet window has transparent background where supported by the platform.
- Pet window is always-on-top by default.
- Pet window does not steal focus from the active editor/terminal where supported.
- Pet window is draggable by the user.
- Pet window remembers its last position across app restarts.
- Initial default position is near the bottom-right of the primary display work area.
- Initial position is clamped to the visible work area.
- Stored position is clamped if it is no longer visible/safe.
- `Show Default Pet` / `Hide Default Pet` tray item works for the persistent default pet.
- `Pause All Pets` / `Resume All Pets` visibly affects the pet.
- Closing placeholder task windows does not close the pet or quit the app.
- Quitting OpenPets closes the pet window cleanly.
- Phase 01 placeholder windows and tray behavior continue to work.
- Known platform limitations are documented after manual testing.
- Hidden state is session-only in Phase 02: every fresh app launch shows the default pet.
- Display changes while running re-clamp the pet to a visible work area.
## Proposed files/directories
Likely update:
```text
apps/desktop/package.json
apps/desktop/src/main.ts
apps/desktop/src/tray.ts
apps/desktop/src/state.ts
apps/desktop/src/windows.ts
README.md
```
Likely add:
```text
apps/desktop/src/pet-window.ts
apps/desktop/src/pet-state.ts
apps/desktop/src/default-pet-controller.ts
apps/desktop/src/display.ts
```
Exact file names can change if a simpler structure is better.
## Technical approach
Keep this phase intentionally simple and stable.
### Pet rendering
Use a minimal bundled default pet renderer for Phase 02.
Recommended approach:
- Render a small hardcoded pet placeholder using inline generated HTML/CSS/SVG or canvas from TypeScript.
- Avoid adding a real spritesheet animation system until the pet format/catalog phases unless it is simpler to reuse a safe bundled asset.
- The pet should visibly look like a pet/companion, not a blank test square.
- Pause/resume should visibly change the pet state, for example normal idle animation vs still/dimmed/paused label.
- Do not put required runtime pet assets under `src/` unless an explicit copy strategy is added.
- Prefer inline generated content for Phase 02 so plain `tsc` builds remain sufficient.
This phase is about window behavior, not final pet art or animation fidelity.
### Pet window behavior
Use a dedicated `BrowserWindow` for the default pet.
Use fixed Phase 02 window bounds:
```text
width: 180 DIP
height: 180 DIP
margin from work area: 24 DIP
```
The window should be tight around the visible pet to avoid a large transparent click-blocking overlay.
Recommended BrowserWindow properties:
- `frame: false`
- `transparent: true`
- `resizable: false`
- `skipTaskbar: true`
- `alwaysOnTop: true`
- secure `webPreferences` similar to Phase 01 placeholders
Use conservative always-on-top behavior. Do not use aggressive screen-saver/topmost hacks in Phase 02.
Focus behavior:
- When showing the pet automatically or from tray, use `showInactive()` where practical so the pet does not steal focus from the active editor/terminal.
- Clicking or dragging the pet may focus the pet window on some platforms; this is best-effort and should be documented after manual testing.
- Do not use fragile native hacks in Phase 02.
- If perfect no-focus behavior is not possible on a platform, document it as a known limitation.
Dragging:
- The user should be able to drag the pet window.
- Preferred implementation: CSS `-webkit-app-region: drag` for the pet surface so no IPC/preload is needed.
- If CSS app-region cannot satisfy the behavior, use a narrow preload bridge only for drag movement.
- Do not introduce broad renderer privileges for dragging.
Pause/resume renderer updates:
- Preferred implementation: reload/regenerate the local pet HTML with the paused state.
- Alternative: use a narrow preload bridge only if needed.
- Do not use a broad `executeJavaScript` control channel.
### Default pet ownership model
Add a small default-pet controller/module to own default pet lifecycle.
The tray should not directly manage pet window internals.
Recommended controller API shape:
```ts
showDefaultPet(): void
hideDefaultPet(): void
isDefaultPetVisible(): boolean
setDefaultPetPaused(paused: boolean): void
destroyDefaultPet(): void
```
Responsibilities:
- Create the pet window on app launch.
- Show/hide default pet.
- Track visible state for the current app session.
- Apply paused state to the renderer.
- Persist and restore position.
- Destroy the pet window on app quit.
Hidden state rule:
- `Hide Default Pet` hides the window but does not destroy it.
- Hidden state is session-only in Phase 02.
- On fresh app launch, the default pet appears automatically again.
- Position persists; hidden/visible preference does not persist yet.
### Persistence
Persist only the minimum state needed for Phase 02:
- Default pet position.
- Paused state may remain in-memory for this phase unless persistence is trivial.
Use Electron `app.getPath("userData")` for local app state.
Recommended state file:
```text
<userData>/openpets-state.json
```
State writes should be simple and safe:
- JSON file.
- Atomic write where practical: write temp file then rename.
- Validate/read defensively.
- Ignore corrupted state with a dev log and fall back to defaults.
### Safe positioning
Use the primary display work area for Phase 02.
Rules:
- Position values use Electron DIP coordinates.
- Store integer `x` and `y` values for the full pet window bounds.
- Initial position: bottom-right of primary display work area with 24 DIP margin.
- Stored position: clamp to visible work area on launch.
- Clamp the full 180x180 DIP window so it remains fully visible.
- Do not attempt advanced multi-monitor restoration yet.
- If displays change, keep the pet visible rather than preserving exact off-screen coordinates.
- Listen for display changes where practical and re-clamp the default pet to the primary work area.
### Tray integration
Update the Phase 01 tray item:
- If default pet visible: show `Hide Default Pet`.
- If default pet hidden: show `Show Default Pet`.
- Clicking toggles the default pet window.
- `Pause All Pets` / `Resume All Pets` updates the pet visual state.
Default pet lifetime:
- Default pet is persistent.
- It is not closed due to agent lifecycle in future phases.
- In this phase, hiding does not destroy the pet window.
- The pet window is destroyed only when OpenPets quits.
## Risks and tradeoffs
### Risk: no-focus behavior differs by platform
Electron focus behavior varies by macOS/Windows/Linux.
Mitigation:
- Use documented Electron APIs only.
- Prefer stable behavior over hacks.
- Document platform limitations after manual testing.
### Risk: transparent windows behave differently by platform
Transparent frameless windows can vary on Linux/window managers.
Mitigation:
- Implement best-effort Electron transparent window behavior.
- Keep fallback acceptable: pet remains visible even if transparency is imperfect.
- Document limitations.
### Risk: dragging requires IPC/preload before local IPC phase
Dragging may require communication between renderer and main process, but Phase 05 local IPC is a separate adapter/app control plane.
Mitigation:
- If needed, use a very narrow Electron preload bridge only for pet window dragging.
- Do not implement external/local OpenPets IPC in this phase.
- Keep the bridge private to the pet renderer.
### Risk: state file corruption
Simple JSON state can be corrupted by interrupted writes or manual edits.
Mitigation:
- Read defensively.
- Validate shape.
- Atomic write where practical.
- Fall back to defaults without crashing.
### Risk: scope creep into pet engine
It is tempting to build the final sprite/animation system now.
Mitigation:
- Keep rendering minimal.
- Build only enough to verify floating pet behavior.
- Defer final manifest/spritesheet rendering to catalog/pet-format phases.
### Risk: pet window blocks clicks over transparent areas
Transparent BrowserWindows still have rectangular bounds and may intercept clicks.
Mitigation:
- Keep the Phase 02 pet window small and tight around the pet.
- Do not create a large transparent overlay.
- Defer click-through/advanced hit-testing behavior unless it becomes necessary.
## Security/privacy notes
This phase should not touch coding-agent configs, Claude settings, MCP, local adapter IPC, network, or pet zip extraction.
Security/privacy expectations:
- No telemetry.
- No network calls.
- No remote content loaded in pet window.
- No config file edits outside OpenPets own userData state file.
- No shell command execution.
- Pet renderer uses hardcoded local content only.
- Pet renderer includes a restrictive CSP.
- Pet renderer denies navigation, redirects, and new windows.
- Pet renderer logs load failures and renderer crashes clearly during development.
- BrowserWindow security defaults are mandatory:
- `nodeIntegration: false`
- `contextIsolation: true`
- `sandbox: true` where compatible with required drag behavior
- no remote content
- deny navigation/new windows
If a preload bridge is needed for dragging, it must expose only minimal drag APIs and no general Node/Electron access.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Manual app run command:
```bash
pnpm --filter @open-pets/desktop dev
```
Expected automated result:
- TypeScript checks pass.
- Desktop app build passes.
- Existing workspace package checks continue to pass.
## Manual verification guide
After implementation, the user should verify:
1. Start the desktop app:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Confirm no dashboard/main window opens automatically.
3. Confirm tray icon/menu still appears.
4. Confirm the default pet appears automatically.
5. Confirm the pet is a small frameless floating window.
6. Confirm the pet stays above normal windows where supported.
7. Confirm clicking/dragging the pet does not unexpectedly steal focus from the active app where supported.
8. Drag the pet to a new position.
9. Quit OpenPets from the tray.
10. Restart OpenPets and confirm the pet position persists.
11. Use tray `Hide Default Pet` and confirm the pet hides without quitting the app.
12. Use tray `Show Default Pet` and confirm the same default pet returns.
13. Hide the pet, quit OpenPets, restart OpenPets, and confirm the pet appears again because hidden state is not persisted in Phase 02.
14. Toggle `Pause All Pets` and confirm the pet visibly changes to paused/quiet state.
15. Toggle `Resume All Pets` and confirm the pet returns to normal state.
16. Open/close placeholder Pet Manager, Configure Agents, and Settings windows and confirm the pet remains alive.
17. If practical, move/change displays and confirm the pet remains visible or is re-clamped on restart.
18. Quit OpenPets and confirm all windows close cleanly.
Manual acceptance question:
```text
Does Phase 02 pass on your machine: default pet appears, can be dragged, persists position, show/hide works, pause/resume is visible, and quit is clean?
```
## Oracle plan review
Oracle reviewed the initial Phase 02 spec and blocked implementation until lifecycle, visibility, asset, focus/drag, controller, and security details were tightened.
Summary of required Oracle feedback:
- Clarify default pet launch visibility and hidden-state persistence.
- Clarify hide vs destroy behavior.
- Add a default-pet controller ownership model instead of tray owning lifecycle.
- Replace required `src/assets/default-pet.svg` asset idea with inline generated content or explicit copy strategy.
- Define fixed window size and clamping rules.
- Define focus behavior precisely, especially `showInactive()` and click/drag limitations.
- Choose drag strategy order.
- Define pause/resume renderer update mechanism.
- Add pet-window security hardening details.
- Add manual verification for hidden/restart and display re-clamping behavior.
## Oracle feedback disposition
- Fixed: Default pet appears on every fresh launch; hidden state is session-only in Phase 02.
- Fixed: `Hide Default Pet` hides but does not destroy the pet window; destroy happens on app quit.
- Fixed: Added default-pet controller ownership model and API shape.
- Fixed: Replaced runtime asset recommendation with inline generated pet HTML/CSS/SVG/canvas.
- Fixed: Added fixed 180x180 DIP window size, 24 DIP margin, integer DIP coordinates, and full-window clamping rules.
- Fixed: Clarified `showInactive()` focus behavior and click/drag best-effort limitations.
- Fixed: Chose CSS `-webkit-app-region: drag` first; narrow preload bridge only if necessary.
- Fixed: Chose reload/regenerate local pet HTML for pause/resume first; narrow preload only if necessary.
- Fixed: Added CSP/navigation/new-window/load-failure/render-crash security requirements for pet renderer.
- Fixed: Added manual verification for hidden/restart behavior and display re-clamping.
## Oracle implementation review
Oracle reviewed the implemented Phase 02 diff after successful validation with:
```bash
pnpm check && pnpm typecheck && pnpm build
```
Initial implementation review blocked manual verification until two issues were fixed:
- The initial tray menu label could be stale because the tray was created before the default pet was shown.
- A pending debounced position save could read from a destroyed pet window if the user dragged and quit quickly.
Implementation review disposition:
- Fixed: Exported `refreshTrayMenu()` and called it after `showDefaultPet()` on startup so the tray label reflects the visible pet.
- Fixed: Guarded the debounced position save with `window.isDestroyed()` before reading window position.
Oracle re-reviewed the fixes and approved Phase 02 for manual user verification.

View file

@ -0,0 +1,481 @@
# Phase 03: Local state and Pet Manager basics
## Goal
Turn the current desktop shell into a minimally real local-first app by formalizing app state and making Pet Manager / Settings show and update the built-in default pet behavior.
This phase should prove that OpenPets owns local preferences and installed-pet state before remote catalog installation, MCP, or Claude integration are added.
## Non-goals
This phase does not implement:
- Remote v2 pet catalog fetching.
- Downloading or installing pet zip files.
- Removing downloaded pets.
- Multiple real pet assets beyond the bundled built-in pet.
- Full pet manifest/spritesheet engine.
- Agent setup detection/configuration.
- Local adapter IPC.
- MCP integration.
- Claude hooks.
- Speech bubbles.
- First-run onboarding.
- Production packaging.
## User-visible/manual outcome
After this phase, the user/developer should be able to run the app and confirm:
```text
Pet Manager shows the bundled built-in pet as installed/default/protected, Settings controls core local preferences, and those preferences persist across restarts.
```
## Acceptance criteria
- Existing tray shell and default pet window behavior continue to work.
- App state has a clear versioned schema.
- State is loaded after `app.whenReady()` and not at module import time.
- State remains local-only under Electron `app.getPath("userData")`.
- State reads are defensive and corrupted/invalid state falls back safely.
- State writes are atomic where practical.
- `app-state.ts` is the only owner/writer of `openpets-state.json`.
- Phase 02 `pet-state.ts` is retired or delegates to `app-state.ts` so pet position writes cannot clobber preferences/pets.
- Existing unversioned Phase 02 state is migrated to V1 while preserving default pet position.
- Built-in pet exists in app state as an installed/protected pet.
- Built-in pet cannot be removed.
- Built-in pet is the default pet unless the state says otherwise.
- Pet Manager is upgraded from placeholder to a minimal real local view.
- Pet Manager shows installed pets, including the built-in pet.
- Pet Manager shows badges/states for `Built-in`, `Installed`, `Default`, and `Protected` where applicable.
- Pet Manager shows that built-in pet removal is unavailable/disabled.
- Pet Manager can set an installed pet as default; in Phase 03 this may be a no-op if only the built-in pet exists, but the UI/state path should be real.
- Settings is upgraded from placeholder to a minimal real preferences view.
- Settings can control `open default pet on app launch`.
- Settings can control `speech bubbles enabled`, even though speech bubbles are not implemented yet.
- `petScale` may exist as inert schema state with default `1`, but visible pet scale controls are deferred to a later phase.
- Settings can reset the default pet position.
- Preferences persist across app restarts.
- If `open default pet on app launch` is disabled, app starts tray-first without showing the default pet.
- `Show Default Pet` can still show the default pet manually when launch-open is disabled.
- If `open default pet on app launch` is re-enabled, the pet appears on future launches.
- Phase 02 hidden-state rule remains: hiding from tray is session-only and is not the same as disabling open-on-launch.
- Automated checks pass.
## Proposed files/directories
Likely update:
```text
apps/desktop/src/main.ts
apps/desktop/src/tray.ts
apps/desktop/src/windows.ts
apps/desktop/src/default-pet-controller.ts
apps/desktop/src/pet-state.ts
apps/desktop/src/pet-window.ts
README.md
```
Likely add:
```text
apps/desktop/src/app-state.ts
apps/desktop/src/built-in-pet.ts
apps/desktop/src/settings-controller.ts
apps/desktop/src/pet-manager-controller.ts
apps/desktop/src/renderer-html.ts
apps/desktop/src/preload.ts # only if needed for safe internal Electron UI actions
```
Exact file names can change if a simpler structure is better.
## Technical approach
Keep Phase 03 local and minimal. This is not the catalog phase and not a full UI framework phase.
### App state model
Formalize a versioned local app state file.
Recommended state path:
```text
<userData>/openpets-state.json
```
Recommended state shape:
```ts
interface OpenPetsStateV1 {
version: 1;
preferences: {
defaultPetId: string;
openDefaultPetOnLaunch: boolean;
speechBubblesEnabled: boolean;
petScale: number;
};
pets: {
installed: Array<{
id: string;
displayName: string;
builtIn: boolean;
protected: boolean;
}>;
};
defaultPet: {
position?: {
x: number;
y: number;
};
};
}
```
Default state:
- `defaultPetId`: built-in pet id.
- `openDefaultPetOnLaunch`: `true`.
- `speechBubblesEnabled`: `true`.
- `petScale`: `1`.
- Installed pets contains the built-in pet.
State rules:
- Built-in pet is always present, even if an old/corrupt state omits it.
- Default pet falls back to built-in pet if missing/invalid.
- Canonical built-in pet metadata wins over state file metadata.
- Protected/built-in flags cannot be disabled by a user-edited/corrupt state file.
- `petScale` remains `1` in Phase 03 unless later implemented.
- Position should continue to use safe Phase 02 clamping rules.
- State-derived strings must be treated as untrusted and escaped before rendering.
### State ownership
Move Phase 02 position persistence into the broader app state module as a required part of this phase.
Avoid split-brain state where different modules write unrelated JSON shapes to the same file.
Recommended ownership:
- `app-state.ts` owns reading/writing `openpets-state.json`.
- Controllers request state updates through narrow functions.
- State is loaded only after `app.whenReady()`.
- No app state reads happen at module import time.
- `pet-state.ts` should be removed or changed to delegate to `app-state.ts`.
- No module other than `app-state.ts` writes directly to `openpets-state.json`.
- All state updates are read/modify/write against one in-memory normalized state object.
- State updates should be serialized in-process to avoid debounced pet-position saves clobbering Settings updates.
Recommended `app-state.ts` API shape:
```ts
initializeAppState(): void
getAppStateSnapshot(): OpenPetsStateV1
updatePreferences(patch: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1
setDefaultPet(defaultPetId: string): OpenPetsStateV1
setDefaultPetPosition(position: { x: number; y: number }): OpenPetsStateV1
resetDefaultPetPosition(): OpenPetsStateV1
```
The API can differ if implementation finds a simpler shape, but the ownership rules must remain.
Startup ordering:
1. `app.whenReady()` resolves.
2. `app.setName("OpenPets")` runs.
3. App state is loaded/normalized through `initializeAppState()`.
4. Tray is created.
5. Display/lifecycle handlers are installed.
6. Default pet is shown only if `openDefaultPetOnLaunch` is true.
7. Tray menu is refreshed so labels match actual pet visibility.
Migration rule:
- Phase 02 already wrote unversioned state shaped like `{ defaultPet: { position } }`.
- Phase 03 must read that V0/unversioned shape and migrate it to V1.
- The migrated V1 state must preserve valid `defaultPet.position`.
- Unknown/corrupt state should not crash the app.
### Built-in pet model
Define one bundled built-in pet record in code.
Recommended values:
```ts
id: "builtin"
displayName: "Built-in Pet"
builtIn: true
protected: true
```
This is a local app-state pet record, not the final downloadable pet manifest contract.
### Pet Manager basics
Upgrade the Pet Manager placeholder into a minimal real local view.
Recommended UI content:
- Title: `Pet Manager`.
- Installed pets section.
- Built-in pet card/row.
- Badges: `Built-in`, `Installed`, `Default`, `Protected`.
- Disabled remove action for built-in pet.
- `Set Default` action for installed pets that are not default.
- Empty/loading/catalog messaging should clearly say catalog browsing arrives in a later phase.
Phase 03 does not need a polished catalog grid. It only needs to prove local installed/default state.
### Settings basics
Upgrade the Settings placeholder into a minimal real preferences view.
Initial settings:
- `Open default pet on app launch` toggle.
- `Speech bubbles enabled` toggle.
- `Pet scale` visible disabled/deferred row or omitted from UI; do not implement scaling controls in Phase 03.
- `Reset default pet position` button.
Settings behavior:
- Changes persist to local state.
- Toggling `Open default pet on app launch` affects next app launch, not necessarily the current pet visibility.
- Reset position should move the visible default pet back to bottom-right immediately if visible and update persisted state.
- If the pet is hidden, reset position updates persisted state; the next show/restart uses the reset position.
- If the pet window has not been created because `openDefaultPetOnLaunch` is disabled, reset position updates persisted state only.
- Speech setting persists but has no visible speech behavior yet.
### Internal renderer interaction
If Pet Manager/Settings need clickable controls, use safe internal Electron IPC/preload only for those task windows.
Important distinction:
- This is Electron renderer-to-main IPC for app UI.
- This is not the Phase 05 local adapter IPC protocol for MCP/CLI/Claude packages.
Required preload/security behavior if used:
- `contextIsolation: true`.
- `nodeIntegration: false`.
- `sandbox: true` where compatible.
- Preload exposes only narrow methods needed for Phase 03 UI actions.
- No generic filesystem, shell, or Electron object exposure.
- Validate all action names/payloads in main process.
- Validate sender `webContents` belongs to an allowed internal task window.
Recommended preload/API contract:
```ts
window.openPets.getState(): Promise<OpenPetsStateV1>
window.openPets.updatePreferences(patch: {
openDefaultPetOnLaunch?: boolean;
speechBubblesEnabled?: boolean;
}): Promise<OpenPetsStateV1>
window.openPets.setDefaultPet(petId: string): Promise<OpenPetsStateV1>
window.openPets.resetDefaultPetPosition(): Promise<OpenPetsStateV1>
```
Rules:
- Do not expose a generic `invoke(action, payload)` API.
- Do not expose filesystem/shell/Electron access.
- Validate booleans as booleans.
- Validate `petId` as a known installed pet id before setting default.
- Validate that reset-position requests come from Settings.
- Renderer should query latest state when opened/focused and after mutations.
- Avoid `webContents.executeJavaScript` as a state/control channel.
If a simpler approach can keep views read-only and use tray/menu actions for state changes, that is acceptable only if acceptance criteria remain met. Since Settings needs toggles/buttons, a narrow preload bridge is likely appropriate.
### Window/rendering approach
Do not introduce React/Vue/Vite yet.
Use generated local HTML for Pet Manager and Settings, similar to prior phases, unless implementation complexity becomes unreasonable.
Renderer content should:
- Be local/hardcoded.
- Include restrictive CSP.
- Deny navigation/new windows.
- Log load/render failures.
- Escape all state-derived strings before insertion into HTML.
## Risks and tradeoffs
### Risk: state shape churn
Early state schemas can become hard to migrate later.
Mitigation:
- Add `version: 1` now.
- Keep schema minimal.
- Validate defensively.
- Normalize state on read to ensure built-in/default invariants.
### Risk: overbuilding UI before real catalog
Pet Manager could become a dashboard or fake catalog.
Mitigation:
- Only show installed local pets in Phase 03.
- Clearly label catalog browsing as a later phase.
- Do not add search/categories/catalog fetch yet.
### Risk: internal IPC scope creep
Adding preload/UI IPC could become too broad.
Mitigation:
- Expose only Phase 03 actions.
- Validate payloads.
- Validate sender window/webContents.
- Do not expose filesystem/shell/Electron.
- Keep this separate from the future local adapter IPC.
### Risk: position state regression
Moving Phase 02 position persistence into app state could break drag/restart behavior.
Mitigation:
- Preserve Phase 02 manual verification.
- Keep position clamping and atomic writes.
- Re-run drag/restart verification.
## Security/privacy notes
This phase remains local-only.
Security/privacy expectations:
- No telemetry.
- No network calls.
- No remote content.
- No coding-agent config edits.
- No shell command execution.
- No pet zip extraction.
- App writes only its own local state file under `app.getPath("userData")`.
- Renderer IPC, if used, is narrow and validated.
- Built-in pet cannot be removed through UI or state corruption.
- State files are treated as untrusted input.
- State-derived renderer content is escaped.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Manual app run command:
```bash
pnpm --filter @open-pets/desktop dev
```
Expected automated result:
- TypeScript checks pass.
- Desktop app build passes.
- Existing workspace package checks continue to pass.
## Manual verification guide
After implementation, the user should verify:
1. Start the desktop app:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Confirm tray and default pet still appear.
3. Open `Manage Pets...`.
4. Confirm the built-in pet is shown as installed/default/protected.
5. Confirm built-in pet cannot be removed.
6. Confirm setting built-in pet as default is disabled/no-op because it is already default.
7. Open `Settings...`.
8. Toggle `Speech bubbles enabled`, quit, restart, and confirm the setting persisted.
9. Toggle `Open default pet on app launch` off, quit, restart, and confirm the default pet does not appear automatically.
10. Use tray `Show Default Pet` and confirm the default pet can still be shown manually.
11. Toggle `Open default pet on app launch` back on, quit, restart, and confirm the default pet appears automatically again.
12. Drag the pet to a new position, quit, restart, and confirm position still persists.
13. Use `Reset default pet position` and confirm the pet moves back near bottom-right and persists there after restart.
14. If a Phase 02 unversioned state file exists, confirm position is preserved after Phase 03 migration.
15. If practical, temporarily corrupt `openpets-state.json`, restart, and confirm app falls back safely with built-in pet/default preferences.
16. Confirm placeholder/real task windows still do not quit the app when closed.
17. Quit OpenPets and confirm clean exit.
Manual acceptance question:
```text
Does Phase 03 pass on your machine: Pet Manager shows built-in pet state, Settings preferences persist, open-on-launch works, reset position works, and Phase 02 pet behavior still works?
```
## Oracle plan review
Oracle reviewed the initial Phase 03 spec and blocked implementation until state ownership/migration and internal preload/IPC details were tightened.
Summary of required Oracle feedback:
- Make Phase 02 state migration mandatory.
- Retire or delegate `pet-state.ts` so there is one state-file writer.
- Add V0/unversioned state migration preserving position.
- Define startup ordering and open-on-launch gating.
- Define app-state API and whole-state write semantics.
- Define preload/API contract and validation rules.
- Require escaping state-derived renderer content.
- Require windows to render/query latest state after mutations.
- Defer visible pet scale controls.
- Define reset-position behavior for visible, hidden, and not-created pet states.
- Add manual verification for corrupted state and V0 migration.
## Oracle feedback disposition
- Fixed: Made Phase 02 position migration into `app-state.ts` mandatory.
- Fixed: Required `app-state.ts` to be the only owner/writer of `openpets-state.json`.
- Fixed: Added V0/unversioned migration preserving `defaultPet.position`.
- Fixed: Added startup ordering with `initializeAppState()` before tray/show-default-pet logic.
- Fixed: Added recommended app-state API and whole-state update semantics.
- Fixed: Added concrete preload/API contract and validation rules.
- Fixed: Required sender validation for internal renderer IPC.
- Fixed: Required escaping all state-derived renderer content.
- Fixed: Required renderer state refresh after mutations.
- Fixed: Deferred visible pet scale controls; `petScale` remains inert default state only.
- Fixed: Defined reset-position behavior for visible, hidden, and not-yet-created pet states.
- Fixed: Added manual verification for V0 migration and corrupt-state fallback.
## Oracle implementation review
Oracle reviewed the implemented Phase 03 diff after successful validation with:
```bash
pnpm check && pnpm typecheck && pnpm build
```
Oracle found no blocking correctness, security, state migration, or scope issues and approved Phase 03 for manual user verification.
Implementation review disposition:
- Approved: Versioned app state and V0 migration preserve Phase 02 pet position.
- Approved: `pet-state.ts` was removed and `app-state.ts` owns `openpets-state.json`.
- Approved: Open-on-launch gating and reset-position behavior are implemented.
- Approved: Pet Manager and Settings use narrow internal Electron IPC with sender and payload validation.
- Approved: Generated UI uses DOM/text rendering for state-derived values instead of HTML injection.
- Fixed optional: Added `node --check preload.cjs` to the desktop `check` script so the plain JS preload gets syntax validation.
Deferred optional improvements:
- Back up corrupt JSON before overwriting with normalized defaults.
- Add pure tests for state normalization/migration once a test framework exists.
- Derive tray default-pet label from app state when multiple real pets exist.

View file

@ -0,0 +1,411 @@
# Phase 04: V2 pet catalog and installation
Status: superseded by split phases after Oracle review.
Oracle blocked this combined phase because the v2 catalog endpoint is not available yet and safe zip installation is security-critical enough to deserve its own focused phase.
Split replacement:
- `phase-04a-v2-catalog-read-only.md` — v2 catalog contract/generation/fetch validation and read-only searchable Pet Manager UI.
- `phase-04b-safe-pet-installation.md` — safe zip install/remove/default switching/local rendering.
## Goal
Make Pet Manager browse the remote v2 pet catalog and install/remove real downloaded pets locally, while keeping the built-in pet as protected fallback.
This phase should prove the core pet ownership loop:
```text
Fetch catalog → browse/search pets → install pet zip safely → show installed state → set default → remove removable pets
```
## Non-goals
This phase does not implement:
- Pet update system.
- Categories/tags/favorites/ratings/popularity UI.
- Advanced sorting.
- CLI install commands.
- MCP/local adapter IPC.
- Claude integration.
- Speech bubbles.
- First-run onboarding.
- Production packaging.
- Full animation/physics engine beyond rendering installed pet enough to verify default switching.
## User-visible/manual outcome
After this phase, the user/developer should be able to run the app and confirm:
```text
Pet Manager fetches the v2 catalog, shows available pets, installs a pet, lets the user set it as default, persists it across restart, and can remove removable installed pets.
```
If the catalog cannot be fetched, OpenPets should remain usable with the built-in pet and already-installed pets.
## Acceptance criteria
- Existing tray, default pet, Pet Manager, and Settings behavior continue to work.
- Pet Manager fetches catalog from:
```text
https://openpets.dev/pets/catalog.v2.json
```
- Catalog fetch has loading and error states.
- Catalog fetch failure does not break built-in pet behavior.
- Catalog data is validated before use.
- Pet Manager shows catalog pets in a simple searchable list/grid.
- Pet Manager shows installed/default/protected states.
- User can install a catalog pet from its `zip` URL.
- Downloaded zip is validated before extraction/install.
- Zip install prevents zip-slip/path traversal.
- Zip install has size and file-count limits.
- Zip install never executes scripts from downloaded pets.
- Failed installs clean up partial files.
- Installed pets are stored locally under OpenPets user data.
- Installed pet metadata is persisted in app state.
- User can set an installed non-built-in pet as default.
- Default pet window reflects the selected default pet after setting default.
- Default pet selection persists across restart.
- User can remove removable installed pets.
- Built-in pet cannot be removed.
- Removing the current default pet falls back to the built-in pet.
- App remains usable if installed pet files are missing/corrupt; fallback to built-in pet.
- Automated checks pass.
## Proposed files/directories
Likely update:
```text
apps/desktop/src/app-state.ts
apps/desktop/src/default-pet-controller.ts
apps/desktop/src/pet-window.ts
apps/desktop/src/windows.ts
apps/desktop/preload.cjs
README.md
packages/pet-format/src/index.ts
```
Likely add:
```text
apps/desktop/src/catalog.ts
apps/desktop/src/pet-install.ts
apps/desktop/src/installed-pets.ts
apps/desktop/src/download.ts
apps/desktop/src/zip.ts
```
Exact file names can change if a simpler structure is better.
## Technical approach
Keep this phase focused on safe catalog/install basics, not marketplace polish.
### Catalog contract
Use the agreed v2 catalog URL:
```text
https://openpets.dev/pets/catalog.v2.json
```
Expected minimal catalog shape:
```ts
interface CatalogV2 {
version: 2;
generatedAt: string;
pets: CatalogPetV2[];
}
interface CatalogPetV2 {
id: string;
displayName: string;
description: string;
preview: string;
zip: string;
format: {
type: "spritesheet";
columns: number;
rows: number;
frameWidth: number;
frameHeight: number;
};
}
```
Validation rules:
- `version` must be `2`.
- `pets` must be an array.
- `id`, `displayName`, `description`, `preview`, and `zip` must be strings.
- `id` must be a safe local identifier, for example `/^[a-z0-9][a-z0-9_-]{0,63}$/`.
- `zip` and `preview` must be `https:` URLs.
- `format.type` must be `spritesheet`.
- Format dimensions/counts must be positive finite integers within safe bounds.
Use `packages/pet-format` for shared catalog/pet types and validation helpers if practical.
### Catalog UI
Pet Manager should show:
- Search input.
- Installed pets and catalog pets in one simple view or clearly separated sections.
- Pet name.
- Short description.
- Preview image if safe/practical.
- Badges: `Built-in`, `Installed`, `Default`, `Protected`.
- Actions: `Install`, `Set Default`, `Remove`, disabled where not valid.
- Loading/error states.
Do not add categories/tags/favorites/sorting beyond search.
### Installed pet storage
Store installed pets under app user data.
Recommended directory:
```text
<userData>/pets/<petId>/
```
Installed pet state should include enough metadata to render/manage it later:
```ts
interface InstalledPetState {
id: string;
displayName: string;
description?: string;
builtIn: boolean;
protected: boolean;
localPath?: string;
previewPath?: string;
format?: CatalogPetV2["format"];
source?: {
catalogVersion: 2;
zip: string;
preview: string;
};
}
```
The built-in pet remains a code-defined protected pet and does not need localPath.
### Zip download/install safety
This is the highest-risk part of Phase 04.
Required behavior:
- Download only `https:` zip URLs from validated catalog entries.
- Enforce max download size before/during download.
- Enforce max extracted file count.
- Enforce max individual file size and total extracted size where practical.
- Extract only into a temporary install directory first.
- Reject absolute paths.
- Reject paths containing `..` after normalization.
- Reject entries that would escape the temp install directory.
- Reject symlinks/hardlinks if zip library exposes them.
- Never execute files from downloaded pets.
- Validate required pet files/manifest before finalizing install.
- Move temp install directory into final `<userData>/pets/<petId>` atomically where practical.
- Clean temp directory on failure.
Recommended limits for initial phase:
```text
max zip download: 50 MB
max extracted total: 200 MB
max files: 500
```
These can be adjusted by implementation if real catalog pets need different limits, but the final chosen limits must be documented.
### Installed pet validation
Initial installed pet validation can be minimal but real.
Required:
- Installation directory exists.
- At least one plausible sprite/preview file exists, or the catalog `preview` was cached successfully.
- Catalog format metadata is persisted.
- Pet id/displayName in state match the validated catalog entry.
If the actual zip structure from current catalog is known to contain a manifest, validate that manifest too. Do not invent a complex new manifest requirement if current pets do not have it yet; record the discovered shape in this phase doc after implementation.
### Default pet rendering after install
Phase 04 does not need final spritesheet animation quality.
But when a user sets an installed pet as default, the default pet window should visibly reflect that choice.
Acceptable Phase 04 rendering:
- Built-in pet keeps current generated pet rendering.
- Installed pet default can show cached preview/spritesheet image in the pet window, scaled into the 180x180 pet window.
- If preview/spritesheet fails, fallback to built-in generated pet and show installed/default state in Pet Manager.
Do not build the full animation-state engine yet unless it is simpler than a static preview.
### App state updates
Extend V1 state conservatively or introduce V2 state if necessary.
Rules:
- Built-in pet must always be present and protected.
- Installed pet records are normalized on read.
- Default pet id must point to an installed pet or fallback to built-in.
- Removing current default sets default to built-in.
- State writes remain owned by `app-state.ts`.
### Internal UI IPC/preload
Extend the existing narrow internal UI API only as needed.
Candidate methods:
```ts
getCatalog(): Promise<CatalogUiState>
installPet(petId: string): Promise<OpenPetsStateV1>
removePet(petId: string): Promise<OpenPetsStateV1>
setDefaultPet(petId: string): Promise<OpenPetsStateV1>
```
Rules:
- No generic `invoke(action, payload)`.
- Validate sender window.
- Validate pet id.
- Do not expose filesystem/shell/Electron.
- Render state-derived/catalog-derived strings through DOM text APIs or HTML escaping.
## Risks and tradeoffs
### Risk: Phase 04 is too large
Catalog browsing, downloading, zip validation, install, remove, default switching, and rendering can become large.
Mitigation:
- Keep UI minimal.
- Do not add categories/tags/favorites/update system.
- If zip/install complexity becomes larger than expected, split Phase 04 into:
1. Catalog browse/read-only.
2. Safe install/remove/default.
### Risk: zip extraction security bugs
Zip-slip/path traversal is a real risk.
Mitigation:
- Treat all zip entries as untrusted.
- Normalize and verify every output path.
- Extract to temp directory.
- Clean up on failure.
- Oracle implementation review should focus heavily on install safety.
### Risk: unknown current pet zip structure
Current web/catalog pets may not match a clean v2 manifest structure yet.
Mitigation:
- Scout current `web/public/pets` metadata and one real zip before implementation.
- Do not blindly copy v1 assumptions.
- Keep validation compatible with real available pets while still safe.
- Record discovered zip shape in this phase doc after implementation.
### Risk: remote images/content in Electron UI
Loading remote preview URLs directly in Electron windows can expand attack surface.
Mitigation:
- Prefer fetching/caching previews through main process or use safe image-only rendering.
- Do not load arbitrary remote HTML.
- Keep CSP restrictive.
- Validate preview URL as `https:`.
## Security/privacy notes
This phase introduces network and untrusted zip handling.
Security/privacy expectations:
- Only fetch the catalog URL and validated `https:` pet assets/zips.
- No telemetry.
- No cookies/auth/accounts.
- No remote HTML content.
- No script execution from downloaded pets.
- No shell command execution.
- All downloaded zip contents are treated as untrusted.
- All catalog/download strings rendered in UI are escaped or inserted as text.
- Pet install writes only under OpenPets user data paths.
- Fail closed on validation errors.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Manual app run command:
```bash
pnpm --filter @open-pets/desktop dev
```
Implementation should add unit-style tests for pure validation/path-safety helpers if a lightweight test approach is practical in this phase. If no test framework is added, document why and rely on manual verification plus Oracle review.
## Manual verification guide
After implementation, the user should verify:
1. Start the desktop app:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Open `Manage Pets...`.
3. Confirm built-in pet still appears as installed/default/protected.
4. Confirm catalog loading state appears, then catalog pets appear.
5. Search for a pet by name and confirm filtering works.
6. Install one real catalog pet.
7. Confirm it appears as installed.
8. Set the installed pet as default.
9. Confirm the default pet window visibly changes or shows that pet's preview.
10. Quit and restart; confirm installed pet and default selection persist.
11. Remove the installed pet.
12. Confirm default falls back to built-in if removed pet was default.
13. Confirm built-in pet still cannot be removed.
14. Temporarily disconnect network or block catalog fetch and confirm the app remains usable with installed/built-in pets.
15. Confirm Settings and Phase 03 preferences still work.
16. Quit OpenPets and confirm clean exit.
Manual acceptance question:
```text
Does Phase 04 pass on your machine: catalog loads, search works, a pet installs safely, default switching persists, removal works, and built-in fallback remains reliable?
```
## Oracle plan review
Pending.
## Oracle feedback disposition
Pending.

View file

@ -0,0 +1,336 @@
# Phase 04A: V2 catalog contract and read-only Pet Manager catalog
## Goal
Establish the v2 pet catalog contract and make Pet Manager fetch, validate, cache, search, and display the catalog in read-only mode.
This phase does **not** install pets yet. It prepares the catalog foundation safely before the security-critical zip install phase.
## Non-goals
- Downloading/installing pet zips.
- Removing downloaded pets.
- Setting downloaded pets as default.
- Rendering downloaded pet sprites in the default pet window.
- CLI/MCP/Claude/onboarding/packaging.
- Categories/tags/favorites/ratings/popularity UI.
## User-visible/manual outcome
After this phase:
```text
Pet Manager shows the built-in installed pet and a searchable read-only catalog of available pets from the v2 catalog contract.
```
If catalog fetch fails, Pet Manager still works for installed/built-in pets and shows a clear catalog error.
## Blocking context from Oracle
Oracle found that the target URL is currently not available:
```text
https://openpets.dev/pets/catalog.v2.json
```
Phase 04A must therefore include making a real v2 catalog artifact available in the repository/deploy path, or explicitly add a temporary development fallback while the production endpoint is being deployed.
Updated decision for Phase 04A:
- Also create the real web artifact at `web/public/pets/catalog.v2.json` now that updating `web/` is explicitly approved.
- Update `web/scripts/sync-pets.js` so future `sync:pets` runs regenerate `catalog.v2.json`.
- Create a tracked v2-shaped development fixture under the tracked v2 workspace.
- Desktop app attempts the final production URL first.
- If production URL is unavailable, desktop app falls back only to the tracked v2-shaped fixture.
- Production deployment of `web/public/pets/catalog.v2.json` can be tested after the web app is redeployed.
- Because `web/` is root-ignored, any required web files must be force-added intentionally when committing from the root repo.
## Acceptance criteria
- Existing tray/default pet/Pet Manager/Settings behavior continues to work.
- A v2 catalog contract is defined in code with validation helpers.
- The v2 catalog artifact shape is based on real current pet metadata, not invented fields.
- A deterministic tracked v2 catalog fixture exists for local development and manual verification.
- The real web v2 catalog artifact exists at `web/public/pets/catalog.v2.json`.
- `web/scripts/sync-pets.js` regenerates the v2 catalog artifact.
- The tracked fixture is v2-shaped; no silent v1 `install.json` fallback is allowed.
- Web changes are intentionally included despite `web/` being root-ignored.
- Desktop catalog fetch validates data before use.
- Catalog fetch has loading, success, and error states.
- Catalog fetch failure does not break built-in pet behavior.
- Pet Manager shows installed local pets plus read-only catalog entries.
- Pet Manager search filters catalog pets by display name/description/id.
- Catalog entries show `Install coming in Phase 04B` or disabled install actions.
- Pet Manager does not fetch remote HTML.
- Remote preview rendering is safe or deferred. If preview rendering is included, images are fetched/validated by main process or constrained to safe image-only rendering.
- Automated checks pass.
## Catalog contract
The Phase 04A canonical v2 catalog shape is:
```ts
interface CatalogV2 {
version: 2;
generatedAt: string;
pets: CatalogPetV2[];
}
interface CatalogPetV2 {
id: string;
displayName: string;
description: string;
preview: string;
zip: string;
}
```
Do not require spritesheet `format` fields in Phase 04A unless the catalog generator can derive them from real current pet data.
Validation rules:
- `version` is `2`.
- `generatedAt` is a string.
- `pets` is an array.
- `id` matches `/^[a-z0-9][a-z0-9_-]{0,63}$/`.
- `id` must not be `builtin`; `builtin` is reserved for the protected bundled pet.
- Duplicate ids are rejected.
- `displayName`, `description`, `preview`, and `zip` are strings.
- `preview` and `zip` are `https:` URLs.
- Allowed hostnames are explicit and documented. Initial expected hosts:
- `openpets.dev`
- `zip.openpets.dev`
- Maximum pets: 1000.
- Maximum `id` length: 64.
- Maximum `displayName` length: 120.
- Maximum `description` length: 500.
- Maximum URL length: 2048.
- `generatedAt` must parse as a valid date.
- URL credentials are rejected.
- URLs with non-default ports are rejected.
- Catalog URL is exactly `https://openpets.dev/pets/catalog.v2.json`.
- `preview` URLs must use host `openpets.dev` and path under `/pets/`.
- `zip` URLs must use host `zip.openpets.dev` and path under `/pets/`, even though zips are not downloaded in Phase 04A.
Mapping from current web metadata to v2:
- v2 `id` = existing `installId`.
- v2 `displayName` = existing `displayName`.
- v2 `description` = existing `description` if present, otherwise `""`.
- v2 `preview` = absolute `https://openpets.dev` URL from existing `spritesheetPath`.
- v2 `zip` = absolute zip URL from existing `zipPath`.
`builtin` is reserved and rejected as a catalog pet id.
## Current metadata source
Existing useful web files:
```text
web/public/pets/manifest.json
web/public/pets/install.json
web/app/lib/pets.generated.js
web/scripts/sync-pets.js
```
Phase 04A should scout these before implementation and create both:
- a real web v2 catalog artifact from current web metadata;
- a tracked local v2 fixture for desktop fallback.
Tracked development fixture:
```text
apps/desktop/catalog.v2.fixture.json
```
Production artifact:
```text
web/public/pets/catalog.v2.json
```
Because `web/` is currently ignored by root `.gitignore` and outside the v2 pnpm workspace, web changes must be added intentionally with force-add from the root repo if committing them there.
## Desktop catalog service
Add a small catalog service in the desktop app or `packages/pet-format`:
- Fetch catalog from the target URL.
- Validate response shape.
- Normalize to a UI-safe catalog model.
- Add and track the v2 fixture at `apps/desktop/catalog.v2.fixture.json` during implementation.
- On fetch failure, load the tracked v2 fixture as a development fallback.
- Defer cache file behavior to a later phase unless implementation discovers it is trivial and safe.
- Do not store catalog cache in `openpets-state.json` in Phase 04A.
- Return UI state that clearly distinguishes live catalog, fixture fallback, cached catalog, and error.
Do not cache unvalidated catalog data.
Phase 04A catalog source precedence:
1. Remote live catalog from `https://openpets.dev/pets/catalog.v2.json`.
2. If remote fetch/validation fails, validated tracked fixture at `apps/desktop/catalog.v2.fixture.json`.
3. If fixture validation also fails, show catalog error while keeping installed/built-in pets usable.
Do not use v1 `install.json` or `manifest.json` as runtime fallback in the desktop app.
Fetch requirements:
- Main process only.
- Timeout required.
- Maximum response bytes required.
- Redirect policy required; final URL must still pass protocol/host/port/credential validation.
- Allowed hosts only.
- No cookies/auth.
## Pet Manager read-only UI
Pet Manager should show:
- Installed pets section, including built-in protected pet.
- Catalog section.
- Search input.
- Catalog loading state.
- Catalog error state.
- Catalog empty state.
- Disabled `Install` button/label for catalog pets: `Install in next phase`.
Preview policy for Phase 04A:
- Defer preview image rendering by default.
- Show text-only catalog cards/rows first.
- If preview rendering is added, it must be main-process fetched/validated/cached and rendered as `data:` with `img-src data:` CSP.
- Renderer must not directly fetch arbitrary remote previews.
Do not implement install/remove/default switching for remote catalog pets in Phase 04A.
## Security/privacy notes
This phase introduces catalog network fetch but not zip extraction.
Security expectations:
- Fetch only the catalog URL and optionally validated preview images.
- No cookies/auth/accounts.
- No telemetry.
- No remote HTML.
- No zip downloads yet.
- Catalog strings are untrusted and rendered with DOM text APIs or escaped.
- URL host/protocol validation is mandatory.
- Fail closed on invalid catalog data.
- Catalog fetch happens in the main process only.
- Renderer receives sanitized catalog UI models only.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Phase 04A must add a lightweight validation check that runs under `pnpm check`.
At minimum, the check should validate:
- The tracked fixture is valid v2 catalog data.
- Invalid ids are rejected.
- Duplicate ids are rejected.
- Non-https URLs are rejected.
- Disallowed hosts are rejected.
- `builtin` id is rejected.
## Manual verification guide
1. Run:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Open `Manage Pets...`.
3. Confirm built-in pet still appears as installed/default/protected.
4. Confirm catalog loading state appears.
5. Confirm catalog pets appear if the catalog source is reachable.
6. Search by pet name and confirm filtering works.
7. Confirm catalog pet install buttons/actions are disabled/read-only for Phase 04A.
8. Simulate catalog failure if practical and confirm built-in/installed pets still work with a clear error.
9. Confirm Settings and default pet behavior from Phase 03 still work.
Manual acceptance question:
```text
Does Phase 04A pass on your machine: Pet Manager shows a validated searchable read-only catalog, handles catalog failure, and preserves built-in/default pet behavior?
```
## Oracle plan review
Oracle reviewed the split Phase 04A spec and agreed the split resolves the zip/install security overreach, but blocked implementation until the catalog source/artifact story was concrete.
Summary of required Oracle feedback:
- Choose a concrete v2 catalog artifact/development source because the production endpoint currently returns 404.
- Define v2 `id` mapping from current metadata.
- Make the Phase 04A catalog schema canonical, not “ideal.”
- Use only v2-shaped fallback catalogs; no silent v1 fallback.
- Add validation bounds and exact host/protocol rules.
- Ensure catalog fetch is main-process-only with timeout/byte limits/redirect/final URL validation.
- Decide preview policy; recommended text-only/defer previews.
- Use a separate validated catalog cache file, not `openpets-state.json`.
- Make catalog validation checks mandatory under `pnpm check`.
- Do not rely on ignored untracked `web/` files.
## Oracle feedback disposition
- Fixed: Chose a tracked development fixture at `apps/desktop/catalog.v2.fixture.json`.
- Fixed: Desktop attempts production URL first and falls back only to v2-shaped fixture if unavailable.
- Fixed: Production `web/public/pets/catalog.v2.json` deployment is documented as separate unless explicitly approved.
- Fixed: Defined v2 `id = installId` mapping from current web metadata.
- Fixed: Made the Phase 04A v2 schema canonical.
- Fixed: Disallowed silent v1 fallback.
- Fixed: Added max pets/string/URL bounds, `generatedAt` validation, credential/port rejection, and explicit allowed hosts.
- Fixed: Required main-process fetch with timeout, byte limit, redirect/final URL validation.
- Fixed: Deferred preview rendering by default; renderer does not fetch remote previews.
- Fixed: Required separate validated catalog cache if caching is implemented.
- Fixed: Required catalog validation checks under `pnpm check`.
- Fixed: Clarified implementation must add and track `apps/desktop/catalog.v2.fixture.json`.
- Fixed: Clarified source precedence: remote live catalog, then validated fixture, then error.
- Fixed: Clarified field-specific URL rules for catalog, preview, and zip URLs.
- Updated: User approved updating `web/`, so Phase 04A now also creates `web/public/pets/catalog.v2.json` and updates `web/scripts/sync-pets.js`.
## Oracle implementation review
Oracle reviewed the implemented Phase 04A diff after successful validation with:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Initial implementation review blocked manual verification until catalog fetch hardening was fixed:
- Redirect behavior was too permissive.
- Timeout covered only headers, not full bounded body read.
- Missing stream body fell back to unbounded `response.text()`.
Implementation review disposition:
- Fixed: Remote catalog fetch now uses `redirect: "error"`.
- Fixed: Abort timeout remains active through the full bounded body read and is cleared in `finally`.
- Fixed: Missing response stream body fails closed instead of falling back to unbounded text read.
After the user approved updating and redeploying `web/`, the implementation was extended to create the real production web catalog artifact:
- `web/scripts/sync-pets.js` now writes `catalog.v2.json`.
- `web/public/pets/catalog.v2.json` was generated from current web pet metadata.
- `https://openpets.dev/pets/catalog.v2.json` was deployed and verified to return `200 application/json` with `version: 2`.
Oracle re-reviewed the web update and approved Phase 04A for manual verification.
Manual verification disposition:
- User manually verified and approved Phase 04A.

View file

@ -0,0 +1,403 @@
# Phase 04B: Safe pet installation, removal, and default switching
## Goal
Allow users to install pets from the validated v2 catalog, set an installed pet as the default, render the selected default pet, and remove removable installed pets safely.
This phase turns the Phase 04A read-only catalog into the first real pet ownership flow:
```text
Catalog pet → safe download/install → installed state → set default → visible pet changes → remove → fallback
```
## Non-goals
- Pet update system.
- Categories/tags/favorites/ratings/popularity UI.
- CLI install command.
- MCP/local adapter IPC.
- Claude integration.
- Speech bubbles.
- First-run onboarding.
- Production packaging.
- Full animation-state engine.
- Remote preview rendering in Pet Manager.
## User-visible/manual outcome
After this phase, the user can install a catalog pet, set it as default, see the floating pet window reflect the selected pet, restart and keep the selection, remove it, and safely fall back to the built-in pet.
## Acceptance criteria
- Existing Phase 04A catalog fetch/search/read-only behavior continues to work.
- Pet Manager enables `Install` for catalog pets that are not installed.
- Installing a pet downloads only its validated catalog `zip` URL.
- Zip download uses allowed host/protocol/path rules from Phase 04A.
- Zip download has timeout and byte limits.
- Zip extraction prevents zip-slip/path traversal.
- Zip extraction rejects absolute paths, `..`, backslashes as separators, Windows drive paths, UNC paths, NUL bytes, duplicate normalized paths, case-insensitive collisions, symlinks/hardlinks, encrypted/unsupported entries, and entries that escape the temp directory.
- Zip extraction does not preserve executable permissions.
- Zip extraction never executes downloaded files.
- Install extracts into a temp directory first.
- Install validates required pet files before finalizing.
- Failed installs clean up temp files and do not update installed pet state.
- Successful install moves/copies validated files into `<userData>/pets/<petId>/`.
- Installed pet state is persisted only after install finalization succeeds.
- Install/remove/default state write failures propagate to the UI and do not report success.
- Pet Manager shows installed state after install.
- User can set an installed pet as default.
- Default pet id persists across restart.
- Floating pet window visibly reflects the selected default pet.
- If installed pet rendering fails, the app falls back to built-in pet rendering without crashing.
- User can remove installed non-built-in pets.
- Built-in pet cannot be removed.
- Removing the current default pet sets default back to built-in and refreshes the pet window.
- Remove failures leave app state consistent and understandable.
- Reinstall/overwrite of an already installed pet is not supported in Phase 04B.
- Concurrent duplicate install/remove/default actions for the same pet are prevented.
- Automated checks include zip/path safety tests.
## Required current zip shape
Phase 04B must inspect current real zips before implementation and document the exact accepted shape.
Expected current shape from existing catalog zips:
```text
<pet-id>/pet.json
<pet-id>/spritesheet.webp
```
Required install validation for Phase 04B:
- Exactly one top-level directory is allowed, or files may be normalized from a single safe top-level directory.
- `pet.json` must exist.
- `spritesheet.webp` must exist.
- `pet.json` must be valid JSON.
- `spritesheet.webp` must be present and under size limits.
- The installed pet id comes from the catalog id, not from trusting zip paths.
- Final installed directory contains only normalized `pet.json` and `spritesheet.webp`.
If real zips differ, update this section before implementation and re-check with Oracle.
## Install storage
Store installed pets under:
```text
<userData>/pets/<petId>/
```
Final installed shape for Phase 04B:
```text
<userData>/pets/<petId>/pet.json
<userData>/pets/<petId>/spritesheet.webp
```
Do not store or trust arbitrary absolute paths in app state.
State should store safe metadata and derive local paths from `userData` + safe pet id.
## Download and extraction limits
Initial limits:
```text
max zip download: 50 MB
max extracted total: 200 MB
max files: 500
max individual file: 100 MB
download timeout: 30 seconds
```
If real catalog pets require different limits, update this spec before implementation.
## URL policy
Download URL must come from a validated v2 catalog entry.
Zip URL rules:
- `https:` only.
- Host exactly `zip.openpets.dev`.
- Path starts with `/pets/`.
- No credentials.
- No custom port.
- Redirects should be rejected or manually validated before following.
- Phase 04B should reject redirects for zip downloads by default.
- Final URL must still satisfy all rules.
Install-from-fixture behavior:
- Installing from the validated fixture fallback is allowed in development because fixture entries use the same `zip.openpets.dev` URLs and URL validation rules.
- UI should not hide that the catalog source is fixture fallback.
Reinstall policy:
- If a pet is already installed, `Install` is disabled.
- Phase 04B does not overwrite/reinstall installed pets.
- A future update/reinstall flow can be designed later.
## Zip safety policy
Treat all zip entries as untrusted.
Before extraction:
- Verify zip magic/signature enough to reject obvious non-zip content.
- Use a zip library that reads central directory metadata and exposes entry types/sizes.
- Reject encrypted or unsupported archives.
For each entry:
- Normalize separators.
- Reject `\` as path separator in entry names.
- Reject NUL bytes.
- Reject absolute POSIX paths.
- Reject Windows drive paths such as `C:\...` or `C:/...`.
- Reject UNC paths such as `//server/share` or `\\server\share`.
- Reject any segment equal to `..`.
- Resolve output path against temp install directory and verify it remains inside that directory.
- Reject duplicate normalized paths.
- Reject case-insensitive path collisions.
- Reject symlinks/hardlinks/special files if exposed by the zip library.
- Reject encrypted or unsupported entries.
- Never execute files.
- Do not preserve executable permissions.
- Validate compressed and uncompressed sizes during extraction; do not trust metadata only.
- Reject extra non-directory files beyond the accepted normalized final shape: `pet.json` and `spritesheet.webp`.
Removal path safety:
- Derive removal path only from `<userData>/pets/<safePetId>`.
- Verify resolved path remains inside the pets root before deletion.
- Never remove paths from persisted state directly.
Mandatory tests/checks must cover malicious path cases.
## App state changes
Extend the existing V1 app state shape in a backward-compatible way. Do not introduce a V2 state migration in Phase 04B unless implementation proves V1 extension is insufficient.
Recommended installed pet state:
```ts
interface InstalledPetState {
id: string;
displayName: string;
description?: string;
builtIn: boolean;
protected: boolean;
installed: boolean;
source?: {
catalogVersion: 2;
zip: string;
preview: string;
};
broken?: boolean;
brokenReason?: string;
}
```
Rules:
- Built-in pet remains always present/protected.
- Default pet id must point to an installed pet or fall back to built-in.
- Removing current default sets default to built-in before/with state update.
- If installed files are missing/corrupt on startup, mark the pet broken and fall back to built-in if needed. Do not crash.
- App state is updated only after file operations succeed.
- Broken installed pet policy: mark the pet as `broken: true`, keep it removable in Pet Manager, and fall back to built-in if it is/was the default.
- Do not auto-remove broken pets in Phase 04B.
- Persisted state should not store absolute local paths.
State operation order:
- Install: download zip → validate archive → extract to temp → validate final files → move to final directory → write state → report success.
- Install failure before state write: clean temp/final partial files and leave state unchanged.
- Remove non-default: validate derived removal path → remove installed record from state → delete files; if file deletion fails, keep state consistent and show a warning/error.
- Remove current default: update state so default falls back to built-in and installed record is removed atomically, refresh pet window, then delete files.
- Default switch: validate installed/non-broken pet → update state → refresh pet window; if state write fails, do not refresh/report success.
- Operation locks prevent install/remove/default races for the same pet id.
## Default pet rendering
Phase 04B does not need final animation engine quality.
Rendering rules:
- Built-in pet keeps current generated renderer.
- Installed default pet uses local installed `spritesheet.webp` as a static visible image inside the existing 180x180 pet window.
- Do not load remote image URLs in the pet window.
- Main process reads local installed `spritesheet.webp`, size-checks it, converts it to `data:image/webp;base64,...`, and passes that data URL into generated pet HTML.
- Pet window CSP must allow `img-src data:` and continue to deny remote content.
- Do not use broad `file://` access to `userData` in the pet renderer.
- If installed image fails, fallback to built-in generated renderer and log clearly.
- If installed image fails for the selected default, mark installed pet broken where practical and fall back to built-in rendering.
- Setting default should refresh the visible pet window immediately.
## Pet Manager UI behavior
Catalog card actions:
- Not installed: `Install`.
- Installing: disabled `Installing…`.
- Installed and not default: `Set Default`, `Remove`.
- Installed and default: disabled `Default`, `Remove` if removable.
- Built-in: disabled protected remove.
Show clear errors for install/remove failures.
Disable duplicate actions while an operation for a pet is in progress.
## Internal IPC/preload API
Extend the current narrow internal UI API:
```ts
installPet(petId: string): Promise<OpenPetsStateV1>
removePet(petId: string): Promise<OpenPetsStateV1>
setDefaultPet(petId: string): Promise<OpenPetsStateV1>
```
Rules:
- Sender must be Pet Manager.
- `petId` must be a safe known catalog/installed id.
- No generic invoke API.
- No filesystem/shell/Electron exposure.
- Main process validates all operation preconditions.
## Dependencies
Phase 04B may add a zip library only after checking current, maintained npm options.
Requirements for zip library choice:
- Works on Node/npm without Bun runtime.
- Supports safe iteration/extraction or exposes enough metadata to enforce safety policy.
- Actively maintained.
- Does not require native build steps if avoidable.
Use librarian/docs lookup before selecting the library.
Current recommendation to verify before implementation:
- Use a maintained pure-JS zip library such as `yauzl`/successor or another actively maintained package that supports lazy entry iteration and exposes entry metadata.
- Document final chosen library and rationale in this phase doc before or during implementation review.
Final Phase 04B choice:
- Use `yauzl@3.3.0` with `lazyEntries: true` and `validateEntrySizes: true`.
- Rationale: pure JavaScript, works with Node/npm without Bun or native build steps, exposes central-directory entry metadata before extraction, supports sequential entry processing, exposes compressed/uncompressed sizes, encryption flags, compression method, and external file attributes needed for fail-closed validation.
- Phase 04B extraction still applies OpenPets' own path, size, duplicate, collision, file type, and final-shape validation instead of relying only on the library defaults.
## Risks and tradeoffs
### Risk: zip extraction security bugs
Mitigation:
- Mandatory path-safety tests.
- Mandatory zip magic/central-directory checks where supported by chosen library.
- Temp extraction only.
- Fail closed on suspicious entries.
- Oracle implementation review focuses heavily on install safety.
### Risk: app state/file operation inconsistency
Mitigation:
- Update state only after final install succeeds.
- On remove, update default fallback and state in a safe order.
- Handle partial delete/orphan files by keeping app usable and showing clear state.
### Risk: rendering scope creep
Mitigation:
- Static local spritesheet preview only.
- Defer animation engine to later.
## Security/privacy notes
- No telemetry.
- No cookies/auth/accounts.
- No remote HTML.
- No script execution from downloaded pets.
- No shell command execution.
- No pet zip contents outside OpenPets userData paths.
- All downloaded files are untrusted.
- Fail closed on validation errors.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
```
Phase 04B must add automated checks for:
- Safe zip path normalization.
- Rejection of traversal/absolute/Windows/UNC/NUL paths.
- Duplicate/case-insensitive collision rejection.
- Zip magic/non-zip rejection.
- Fixture/current catalog zip shape validation where practical.
## Manual verification guide
1. Run:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Open `Manage Pets...`.
3. Install a catalog pet, for example Snoopy.
4. Confirm it appears installed.
5. Set it as default.
6. Confirm floating pet visibly changes to the installed pet image.
7. Quit/restart and confirm installed/default state persists.
8. Remove the installed pet.
9. Confirm default falls back to built-in.
10. Confirm built-in cannot be removed.
11. Confirm catalog/search/settings still work.
Manual acceptance question:
```text
Does Phase 04B pass on your machine: safe install works, installed default rendering works, remove/fallback works, and built-in protection remains reliable?
```
## Oracle plan review
Oracle reviewed the initial Phase 04B spec and blocked implementation until state schema, broken pet policy, install/reinstall behavior, zip validation, removal safety, rendering strategy, and dependency choice were sharpened.
## Oracle feedback disposition
- Fixed: Chose backward-compatible V1 state extension instead of V2 migration.
- Fixed: Defined broken installed pet policy: mark broken/removable, fallback to built-in, do not auto-remove.
- Fixed: Explicitly allowed install from validated fixture fallback during development.
- Fixed: Forbid reinstall/overwrite in Phase 04B.
- Fixed: Required install/remove/default state write failures to propagate.
- Fixed: Required final installed directory to contain only `pet.json` and `spritesheet.webp`.
- Fixed: Added zip magic/central-directory validation requirement.
- Fixed: Required streaming download byte limit and timeout through full body read.
- Fixed: Added removal path safety rules.
- Fixed: Chose main-process data URL rendering with `img-src data:` CSP.
- Fixed: Required chosen zip library and rationale to be documented before/with implementation.
Implementation review disposition:
- Fixed: Open `yauzl` with `strictFileNames: true` so backslash entries are rejected before extraction instead of normalized.
- Fixed: App state writes now write the candidate state to disk before mutating in-memory state, preventing state/disk divergence on write failure.
- Fixed: Persisted installed pet ids are revalidated during normalization; invalid entries are dropped.
- Fixed: Startup normalization marks installed pets broken when required local files are missing/corrupt and falls back to built-in if the persisted default is broken.
- Fixed: Zip checks now include duplicate/case-collision entry sets, non-zip magic rejection, empty path segment rejection, and an archive-level `yauzl` `strictFileNames` backslash rejection check.
- Fixed: Remove-file deletion failures now report that state removal succeeded but local files may require manual cleanup.
- Fixed: Tightened initial zip magic acceptance to normal local-file and empty-archive signatures.
- Deferred: Broader archive-level tests for encrypted entries, unsupported compression, and symlink/special-file metadata remain valuable but are not required before manual Phase 04B verification because the implementation rejects those through entry metadata before writing file contents.

View file

@ -0,0 +1,368 @@
# Phase 05: Local IPC and client package
## Goal
Create the private local control plane between the Electron desktop app and Node-based adapters such as `@open-pets/mcp`, `@open-pets/claude`, and `@open-pets/cli`.
After this phase, a developer can run the desktop app and use `@open-pets/client` from Node to call local IPC methods that report app status and make the visible default pet react in a small, manually verifiable way.
## Non-goals
- MCP server package implementation.
- Claude hooks implementation.
- Auto-launching the desktop app from the client.
- Remote HTTP/TCP API.
- Agent lease lifecycle and heartbeat cleanup.
- Full speech bubble product UI.
- Rich animation-state engine.
- Production installer integration.
- Pet install/remove/default over public IPC.
## User-visible/manual outcome
With the desktop app running, a developer can run a local Node command from `packages/client` that connects through a discovery file to the desktop app and calls:
```text
status
pet.react
pet.say
```
The desktop app rejects invalid tokens/versions. `pet.react` and `pet.say` produce a visible temporary state in the existing default pet window, enough to prove the local control plane works before MCP is built.
## Acceptance criteria
- Desktop app starts a local-only IPC server on app ready.
- IPC transport is cross-platform:
- macOS/Linux: Unix domain socket.
- Windows: named pipe.
- Desktop app writes a discovery file while the IPC server is running.
- Discovery file includes protocol version, endpoint, random startup token, app version, platform, and pid.
- Discovery file does not contain durable secrets beyond the current startup token.
- Discovery file and runtime directory use restrictive permissions where supported.
- Unix socket paths are short enough for common `sockaddr_un` limits.
- Unix stale socket files are cleaned up before bind and on normal shutdown.
- Windows named pipe path uses the `\\.\pipe\...` namespace and does not depend on filesystem cleanup.
- Every request includes protocol version and startup token.
- Invalid token requests are rejected.
- Invalid protocol version requests are rejected.
- Malformed JSON and unknown methods return errors without crashing the app.
- IPC request size is bounded.
- The server handles socket/client errors without crashing.
- `@open-pets/client` can read discovery, connect, send one request, and return typed results.
- `@open-pets/client` has connection/request timeout behavior.
- Initial methods are implemented:
- `hello`
- `status`
- `pet.react`
- `pet.say`
- `status` reports whether the app is reachable, app version, protocol version, default pet id, default pet display name, and paused/open-default-pet state where available.
- `pet.react` accepts the initial public reaction names from `task.txt` and visibly updates the default pet label/state temporarily.
- `pet.say` accepts a short message, enforces the Phase speech/message safety rules at a minimal level, and visibly updates the default pet label/message temporarily.
- Public IPC does not expose pet installation, removal, default switching, filesystem, shell, Electron, or generic invoke access.
- Automated checks cover protocol validation and client request/response parsing where practical.
## Proposed files/directories
Expected additions/changes:
```text
docs/phases/phase-05-local-ipc-client.md
apps/desktop/src/local-ipc.ts
apps/desktop/src/local-ipc-protocol.ts
apps/desktop/src/default-pet-controller.ts
apps/desktop/src/pet-window.ts
apps/desktop/src/main.ts
packages/client/src/index.ts
packages/client/src/protocol.ts
packages/client/src/discovery.ts
packages/client/src/check-client-protocol.ts
packages/client/package.json
```
Exact file split may change during implementation if a simpler structure is clearer.
## Technical approach
### Transport
Use Node `node:net` in the Electron main process and in `@open-pets/client`.
Platform endpoint rules:
- macOS/Linux: create a Unix socket under a user-owned OpenPets runtime directory.
- Windows: create a named pipe path like `\\.\pipe\openpets-<user-or-session>-<pid>`.
No TCP listener is used in Phase 05.
### Runtime directory and discovery
Use a shared deterministic discovery location so the app and client can agree without Electron imports in the client package.
Discovery file location:
```text
macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json
Linux: $XDG_RUNTIME_DIR/openpets/ipc.json when secure, otherwise ~/.config/OpenPets/runtime/ipc.json
Windows: %APPDATA%\OpenPets\runtime\ipc.json
```
Socket/pipe endpoint location:
```text
macOS: /tmp/openpets-<uid>/openpets-<pid>.sock
Linux: $XDG_RUNTIME_DIR/openpets/openpets-<pid>.sock when secure, otherwise /tmp/openpets-<uid>/openpets-<pid>.sock
Windows: \\.\pipe\openpets-<random>-<pid>
```
Do not put Unix socket files under the longer application-support discovery directory because common Unix socket path length limits are around 103-107 bytes.
Implementation can centralize path derivation constants in both desktop and client, as long as both sides agree and the path is documented.
Permission targets:
- Runtime directories: `0700` where supported.
- Discovery file: `0600` where supported.
- Unix socket: protected by the runtime directory and `chmod 0600` after bind where supported.
- Windows: rely on user AppData ACLs plus the startup token in Phase 05; do not claim a strong named-pipe ACL unless it is implemented.
Discovery shape:
```json
{
"protocolVersion": 1,
"protocol": "openpets-ipc",
"endpoint": "/short/path/openpets.sock",
"token": "random-startup-token",
"appVersion": "0.0.0",
"pid": 12345,
"platform": "darwin"
}
```
Write discovery atomically through a temp file then rename where practical. Remove the discovery file on normal app shutdown if it still points at the current pid/token.
Treat discovery as untrusted in `@open-pets/client`: reject malformed JSON, wrong protocol, wrong protocol version, mismatched platform, unsupported endpoint shape, TCP-like host/port values, missing token, missing pid, and oversized discovery files.
Threat model: the startup token protects against accidental or cross-user access when file permissions hold. It is not a strong same-user security boundary. A same-user process that can read the discovery file can control the pet for that app run.
### Protocol
Use newline-delimited JSON over the local socket/pipe, one request and one response per connection in Phase 05. This keeps the client simple and avoids stream multiplexing before MCP needs it.
Protocol limits:
```text
max request bytes: 16 KiB
max response bytes in client: 16 KiB
connect timeout: 2 seconds
response timeout: 3 seconds
malformed/no-newline behavior: close with structured error when possible, otherwise close socket
```
Request:
```json
{
"id": "uuid-or-random-id",
"version": 1,
"token": "startup-token",
"method": "status",
"params": {}
}
```
Response:
```json
{
"id": "same-id",
"ok": true,
"result": {}
}
```
Error response:
```json
{
"id": "same-id-if-known",
"ok": false,
"error": {
"code": "invalid_token",
"message": "Invalid IPC token."
}
}
```
### Methods
`hello`:
- Confirms protocol compatibility and token validation.
- Returns app version and protocol version.
`status`:
- Returns current default pet summary and basic app state.
`pet.react`:
- Params: `{ "reaction": "testing" }`.
- Valid reactions: `idle`, `thinking`, `working`, `editing`, `running`, `testing`, `waiting`, `success`, `error`, `celebrating`.
- Updates the existing default pet renderer with a visible label/state for a short TTL.
`pet.say`:
- Params: `{ "message": "On it!", "reaction": "working" }`.
- Message must be short, single-line, not code-block-like, and not obviously secret-like.
- Updates the existing default pet renderer with a visible message for a short TTL.
- Respects `speechBubblesEnabled` and paused state where practical.
- Replaces the current transient message/reaction and resets its TTL instead of queuing unbounded messages.
- All dynamic message/reaction/display text must be HTML-escaped before renderer insertion.
### Client package
`@open-pets/client` should expose typed helpers:
```ts
createOpenPetsClient(options?): OpenPetsClient
client.hello()
client.status()
client.react(reaction)
client.say(message, options?)
```
The client reads discovery by default, connects to the endpoint, sends exactly one request, waits for one response, then closes.
Unavailable/stale discovery semantics:
- `client.status()` should return a typed unavailable/degraded result when discovery is missing, stale, malformed, or connection times out.
- Other method helpers may throw structured client errors for unavailable app state.
### Desktop integration
- Start IPC after app state and default-pet display handlers are initialized.
- Stop IPC during app shutdown.
- `pet.react` / `pet.say` should show the default pet if open-default-pet-on-launch is enabled or if the default pet window is already visible. It should not create extra pet windows beyond the default pet in Phase 05.
- Keep renderer content generated by the main process with a strict CSP. No remote content.
## Risks and tradeoffs
### Risk: local IPC security assumptions
Mitigation:
- Local-only socket/pipe; no TCP.
- Random startup token required on every request.
- Restrictive runtime directory, discovery file, and Unix socket permissions where available.
- Small method surface.
- Request size limit and fail-closed validation.
### Risk: cross-platform runtime path differences
Mitigation:
- Keep endpoint and discovery path logic small and documented.
- Prefer short Unix socket paths.
- Avoid clever platform-specific IPC beyond Node `net`.
### Risk: implementing too much MCP lifecycle too early
Mitigation:
- Phase 05 does not implement leases, heartbeats, MCP tools, auto-launch, or cleanup semantics for agent-owned temporary pets.
- It only proves the private app/client control plane.
## Security/privacy notes
- No remote API.
- No TCP listener.
- No shell command execution.
- No pet install/remove/default mutation over this initial public IPC.
- Startup token is random and per app run.
- Requests are bounded and parsed as untrusted input.
- `pet.say` must not display multiline/code/secret-looking content.
- Discovery file is a local convenience file, not durable account/auth state.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm typecheck
pnpm build
pnpm --filter @open-pets/desktop check
pnpm --filter @open-pets/client check
```
Phase 05 should add checks for:
- Discovery validation rejects malformed/wrong protocol/wrong platform/bad endpoint values.
- Endpoint generation covers Unix and Windows pipe shape without requiring Windows to run tests.
- Request validation rejects missing/invalid token.
- Request validation rejects unsupported protocol version.
- Unknown methods return structured errors.
- Malformed/oversized request handling is fail-closed.
- `pet.say` message validation rejects multiline, code-block-like, overlong, URL/path-heavy, or secret-looking input where practical.
- Client protocol parsing handles ok/error responses.
## Manual verification guide
1. Run the desktop app:
```bash
pnpm --filter @open-pets/desktop dev
```
2. In another terminal, run the client smoke command added by this phase, for example:
```bash
pnpm --filter @open-pets/client smoke:status
pnpm --filter @open-pets/client smoke:react testing
pnpm --filter @open-pets/client smoke:say "Working on it" working
```
3. Confirm status returns app/default-pet information.
4. Confirm invalid token smoke check fails with a structured rejection.
5. Confirm the visible default pet label/state changes for react/say.
6. Quit OpenPets and confirm the client reports that the app is unavailable.
Manual acceptance question:
```text
Does Phase 05 pass on your machine: local IPC discovery works, @open-pets/client can call status/react/say, invalid tokens are rejected, and quitting the app makes the client report unavailable?
```
## Oracle plan review
Oracle reviewed the Phase 05 plan and approved the architecture/scope with should-fix disposition required before implementation.
## Oracle feedback disposition
- Fixed: Made discovery path and socket/pipe endpoint paths concrete, keeping Unix socket paths short.
- Fixed: Required client-side discovery validation as untrusted input and forbade TCP-like discovery behavior.
- Fixed: Added concrete permission targets for runtime directories, discovery files, Unix sockets, and clarified Windows limitations.
- Fixed: Clarified same-user threat model: startup token is not a strong same-user security boundary.
- Fixed: Defined stale/unavailable discovery semantics for `client.status()`.
- Fixed: Added numeric protocol limits for request/response bytes and timeouts.
- Fixed: Required `pet.say` to respect speech/paused controls where practical, replace transient messages, and HTML-escape dynamic text.
- Fixed: Tightened automated check requirements for discovery validation, endpoint generation, invalid token/version/method, malformed/oversized input, say validation, and client response parsing.
- Fixed: Noted package Node typings/build requirement for `@open-pets/client`.
- Deferred: Environment override such as `OPENPETS_DISCOVERY_FILE` is useful for smoke tests and may be implemented if it stays simple.
Implementation review disposition:
- Fixed: Escaped built-in pet transient label/message text before injecting into generated pet HTML.
- Fixed: External `pet.react`/`pet.say` no longer force-show the default pet when the user has hidden it and disabled open-default-pet-on-launch; events only show an already-visible pet or the normal open-on-launch default pet.
- Fixed: Hardened fallback `/tmp/openpets-<uid>` runtime directory handling with ownership, symlink, and mode checks before trusting it.
- Fixed: Discovery file parsing now checks file size before read and wraps malformed JSON in structured client errors.
- Fixed: Discovery cleanup now removes the file only if it still points at the current pid/token/endpoint.
- Fixed: Client-side Unix endpoint validation now only accepts expected OpenPets runtime socket filename/directory patterns.
- Fixed: Paused behavior is quiet: `pet.say` and `pet.react` do not show/update visible transient state while paused.
- Fixed: Windows pipe suffix now uses cryptographic random bytes instead of `Math.random()`.
- Fixed: Reused the protocol transient display TTL constant instead of a hardcoded value.
- Fixed: Corrected macOS/Linux fallback socket endpoint generation to use literal `/tmp/openpets-<uid>` so it matches the documented path and client validation.
- Fixed: Malformed/oversized local IPC requests now return `invalid_request` structured errors instead of generic internal errors where possible.
- Deferred: Response id matching, stronger Windows pipe ACLs, and broader cross-platform endpoint-generation tests can be added with the MCP/packaging phases if needed.

View file

@ -0,0 +1,356 @@
# Phase 06A: Thin `@open-pets/mcp` tools over local IPC
## Goal
Make OpenPets minimally usable from MCP-capable coding agents through a thin Node/npm/npx MCP server package over the existing Phase 05 local IPC methods.
This sub-phase turns the Phase 05 local IPC control plane into the first agent-facing integration:
```text
MCP-capable agent → @open-pets/mcp stdio server → @open-pets/client → desktop local IPC → visible pet
```
## Non-goals
- Claude Code config detection/setup UI.
- Claude enhanced hooks.
- Agent Setup window implementation.
- Pet install/remove/default tools exposed to agents.
- Remote HTTP/TCP MCP server.
- Full packaging/release publishing.
- Complex session/project routing.
- Advanced animation engine.
- Direct app auto-install. MCP may try to launch an installed app later, but must not install it.
## User-visible/manual outcome
After this phase, a developer can run the local `@open-pets/mcp` package as a stdio MCP server, call:
```text
openpets_status
openpets_say
openpets_react
```
and see the running OpenPets desktop pet respond through local IPC.
If OpenPets is not running, the MCP server remains alive and `openpets_status` reports a clear degraded/unavailable status instead of crashing.
This phase is explicitly **not** the full Phase 06 lease lifecycle. Full real `--pet` routing, non-default temporary pet windows, lease heartbeat/release, fallback for missing explicit pets, and TTL cleanup are split into follow-up Phase 06B.
## Acceptance criteria
- `@open-pets/mcp` builds and runs with Node/npm/pnpm and is shaped for future `npx -y @open-pets/mcp` usage.
- Package has a `bin` entry and compiled entrypoint has a Node shebang.
- MCP server uses stdio transport and never writes logs to stdout.
- MCP server exposes exactly these initial tools:
- `openpets_status`
- `openpets_say`
- `openpets_react`
- `openpets_status` calls `@open-pets/client.status()` and reports reachable/degraded app state as both human-readable text and structured content where supported.
- `openpets_say` calls `@open-pets/client.say()` with the user-provided short message and optional reaction.
- `openpets_react` calls `@open-pets/client.react()` with one allowed reaction.
- MCP tool schemas enforce the public reaction list from `task.txt`.
- MCP tool schemas keep `openpets_say.message` short enough for Phase 05 IPC limits and speech safety.
- Tool errors return MCP tool errors (`isError: true`) rather than crashing the server.
- If desktop app/discovery/local IPC is unavailable, `openpets_status` reports degraded state clearly.
- If desktop app/discovery/local IPC is unavailable, `openpets_say` and `openpets_react` return clear tool errors.
- No tool can install pets, remove pets, set default pet, browse catalog, edit settings, access filesystem, run shell commands, or expose generic IPC.
- `--pet <id>` is parsed and included in MCP status/config output.
- `--pet` is clearly reported as configured-but-not-yet-routed in Phase 06A; actual tools still affect the desktop app's current default pet through Phase 05 IPC.
- `openpets_status` clearly distinguishes `configuredPetId` from `actualTargetPetId`.
- If `--pet` is omitted, status reports that the MCP server targets the user's current default pet.
- MCP process lifecycle handlers close the MCP server cleanly on `SIGINT`/`SIGTERM` where available.
- No Bun runtime requirement.
- Automated checks cover MCP argument parsing, tool input validation helpers, and degraded status formatting where practical.
## Scope note: Phase 06A versus 06B
The high-level Phase 06 target in `docs/implementation-process.md` includes immediate lease acquire/release, `--pet` opening explicit non-default pets, fallback for missing explicit pets, and TTL cleanup.
Current implementation state after Phase 05 provides `hello`, `status`, `pet.react`, and `pet.say` over local IPC, but does not yet expose `lease.acquire`, `lease.heartbeat`, or `lease.release`, and the desktop app still has only a persistent default-pet window.
Oracle plan review required this split. This spec is Phase 06A:
```text
Phase 06A: thin MCP tools over default-pet IPC
Phase 06B: MCP lease lifecycle and explicit non-default pet windows
```
Do not silently expand Phase 06A into lease lifecycle work.
## Proposed files/directories
Expected additions/changes:
```text
docs/phases/phase-06a-thin-mcp-tools.md
packages/mcp/package.json
packages/mcp/src/index.ts
packages/mcp/src/args.ts
packages/mcp/src/tools.ts
packages/mcp/src/check-mcp-contract.ts
packages/mcp/tsconfig.json
packages/client/src/index.ts (only if minor client result typing is needed)
pnpm-lock.yaml
```
Exact file split may change if a simpler implementation is clearer.
## Technical approach
### MCP SDK
Use the stable official TypeScript MCP SDK for this npm/npx-facing package:
```text
@modelcontextprotocol/sdk
zod
```
Do not use alpha split SDK packages unless the stable SDK proves incompatible and the reason is documented.
### Server shape
- Entry file starts with `#!/usr/bin/env node`.
- `@open-pets/client`, `@modelcontextprotocol/sdk`, and the schema validation library are runtime dependencies when used by the MCP server.
- Create MCP server with name `open-pets` / package name `@open-pets/mcp`.
- Connect using stdio transport.
- Use stderr for diagnostics only.
- Keep stdout reserved for MCP protocol.
- Register three tools with small descriptions and schemas.
- Keep tool handlers pure/testable by injecting the client/status functions where practical.
### Tools
`openpets_status`:
- No required params.
- Returns app availability, configured `--pet` target if present, default-pet status from the desktop app, and guidance if unavailable.
- Should be safe/read-only/idempotent.
- Sanitizes degraded/unavailable errors before returning them to agents; do not expose home paths, socket paths, tokens, or raw Node stack details.
Structured status shape:
```ts
interface OpenPetsMcpStatus {
ok: boolean;
appRunning: boolean;
configuredPetId?: string;
actualTargetPetId?: string;
actualTargetPetName?: string;
usingDefaultPet: boolean;
routingImplemented: false;
unavailableReason?: string;
fallbackReason?: string;
}
```
`openpets_react`:
Params:
```json
{
"reaction": "testing"
}
```
Valid reactions:
```text
idle
thinking
working
editing
running
testing
waiting
success
error
celebrating
```
`openpets_say`:
Params:
```json
{
"message": "Working on it",
"reaction": "working"
}
```
Rules:
- Message max 140 characters.
- Single-line.
- No code-block-like content.
- No obvious secrets, URLs, or long path-like content.
- Optional reaction must be from the public reaction list.
The desktop IPC remains the final enforcement layer; MCP schemas are an early validation layer.
### CLI args
Parse only the initial simple args:
```text
--pet <petId>
--help
--version
```
Rules:
- Pet id uses the same safe id shape as catalog pets.
- Do not add `--agent` or `--workspace`.
- Do not make `--pet` silently install pets.
- For Phase 06A-style behavior, `--pet` is reported in status but may not yet open non-default pets until lease lifecycle is implemented.
- If `--pet` is provided, `openpets_status` must explicitly say explicit pet routing is not implemented in Phase 06A and the actual target remains the desktop default pet.
### Degraded behavior
`@open-pets/client.status()` already returns a typed unavailable result when discovery/connect fails. MCP should preserve this behavior in `openpets_status`.
For `say`/`react`, return `isError: true` with a concise message such as:
```text
OpenPets desktop app is not running or local IPC is unavailable. Open OpenPets and try again.
```
### Lifecycle
- Handle `SIGINT`/`SIGTERM` by closing MCP server/transport where supported.
- Keep server alive even if OpenPets desktop app is unavailable.
- Do not implement public `openpets_release`.
Phase 06B lease lifecycle must later:
- Acquire on MCP startup, not first tool call.
- Heartbeat while alive.
- Release on shutdown/signals/stdin close where practical.
- Use desktop TTL fallback for orphan cleanup.
- Fall back to default pet for missing explicit pet and report fallback in status.
## Risks and tradeoffs
### Risk: SDK API drift
Mitigation:
- Verify current official MCP TypeScript package before implementation.
- Keep SDK wrapper code small.
- Add package-local checks.
### Risk: stdout corruption
Mitigation:
- Never use `console.log` in MCP runtime path.
- Use stderr for diagnostics.
### Risk: scope creep into full lease/multi-pet lifecycle
Mitigation:
- Thin MCP tools over existing local IPC are the manually verifiable Phase 06A checkpoint.
- Lease lifecycle is explicitly Phase 06B.
### Risk: agents displaying private content through `openpets_say`
Mitigation:
- MCP schema limits message shape.
- Desktop IPC validates speech safety again.
- Tool descriptions explicitly say not to send code, logs, secrets, URLs, or file paths.
## Security/privacy notes
- MCP server is stdio only.
- No TCP/HTTP listener.
- No filesystem/shell/Electron access exposed to agents.
- Agent-facing tool surface stays minimal.
- Pet management remains user-controlled in the desktop app.
- `openpets_say` is for short status/personality messages only.
- No Bun runtime dependency.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm --filter @open-pets/mcp check
pnpm --filter @open-pets/mcp build
```
Phase 06A should add checks for:
- CLI arg parsing for `--pet`, `--help`, `--version`, invalid args.
- Reaction validation accepts only public reactions.
- `openpets_say` validation rejects overlong/multiline/code/secret/path/URL-like messages.
- Degraded status formatting when client reports unavailable.
- Built output has a shebang or package bin points at the expected executable.
- MCP contract check starts the stdio server and verifies `tools/list` returns exactly `openpets_status`, `openpets_say`, and `openpets_react`.
- Contract check verifies unavailable app does not crash the MCP server.
- Contract check verifies no unexpected stdout noise outside MCP protocol messages.
## Manual verification guide
1. Run desktop app:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Build MCP package:
```bash
pnpm --filter @open-pets/mcp build
```
3. Run an MCP inspector or local MCP client against the package entrypoint.
4. Call `openpets_status` and confirm it reports OpenPets reachable/default pet info.
5. Call `openpets_react` with `testing` and confirm the visible pet reacts.
6. Call `openpets_say` with `Working on it` and `working` and confirm the visible pet message appears.
7. Quit OpenPets and call `openpets_status` again; confirm it reports degraded/unavailable clearly while MCP remains alive.
Manual acceptance question:
```text
Does Phase 06A pass on your machine: @open-pets/mcp runs over stdio, exposes status/say/react, talks to the running desktop app through local IPC, reports configured `--pet` as not-yet-routed, and reports degraded status when OpenPets is unavailable?
```
## Oracle plan review
Oracle reviewed the initial Phase 06 plan and blocked calling it complete Phase 06 because real lease lifecycle and `--pet` routing are larger cross-cutting desktop/client work. Oracle approved the architecture only as Phase 06A.
## Oracle feedback disposition
- Fixed: Renamed/split scope to Phase 06A thin MCP tools over local IPC.
- Fixed: Deferred lease lifecycle, real non-default `--pet` windows, heartbeat/release, fallback, and TTL cleanup to Phase 06B.
- Fixed: Required status/manual docs to clearly distinguish configured `--pet` from actual default-pet target in Phase 06A.
- Fixed: Switched SDK plan to stable `@modelcontextprotocol/sdk` instead of alpha split package.
- Fixed: Required runtime dependencies for `@open-pets/client`, MCP SDK, and schema validation library.
- Fixed: Promoted runtime dependency requirement into the package/server acceptance criteria.
- Fixed: Corrected expected spec path and Phase 06A check wording after rename.
- Fixed: Tightened package/npx acceptance: bin entry, shebang, executable compiled entry, no Bun, no stdout logs.
- Fixed: Defined structured MCP status shape.
- Fixed: Required sanitized degraded errors so local paths/tokens/raw Node details are not returned to agents.
- Fixed: Required MCP contract check for tool list, invalid inputs, unavailable app behavior, and stdout protocol cleanliness.
- Fixed: Clarified tool error semantics: unavailable app returns `isError: true` for say/react rather than crashing the server.
- Deferred: Auto-launch of installed OpenPets app is explicitly left for Phase 06B or packaging phase.
Implementation review disposition:
- Fixed: Replaced POSIX-only `chmod +x` build script with a Node-based post-build executable step that no-ops on Windows.
- Fixed: Replaced POSIX `X_OK` contract assertion with shebang/bin validation.
- Fixed: MCP package `check` builds `@open-pets/client` first so package-local checks do not depend on stale client `dist` output.
- Fixed: Added stdio smoke contract coverage using the built `dist/index.js`, verifying tool list and degraded `openpets_status` with a missing discovery file.
- Fixed: Unavailable `openpets_status` human text now still explains configured `--pet` and Phase 06A deferred routing.

View file

@ -0,0 +1,461 @@
# Phase 06B: MCP lease lifecycle and explicit pet routing
## Goal
Complete the core MCP runtime behavior that Phase 06A intentionally deferred: MCP startup should acquire a desktop-app lease for the configured/default pet, explicit non-default `--pet` values should open temporary pet windows, leases should heartbeat while alive, and temporary explicit pets should close when their last active lease ends or expires.
This phase turns the Phase 06A thin tool path into the real agent-neutral lifecycle described in `task.txt`:
```text
@open-pets/mcp --pet snoopy starts
→ desktop app receives lease.acquire
→ snoopy window opens as temporary agent pet
→ tools target snoopy through lease id
→ MCP exits or crashes
→ release or TTL cleanup removes lease
→ snoopy closes if no other lease/user pin needs it
```
## Non-goals
- Claude Code detection/configuration UI.
- Claude enhanced hooks.
- Agent Setup window implementation.
- Auto-launching installed OpenPets desktop app from MCP.
- Production packaging/release validation.
- Pet install/remove/default tools exposed to agents.
- Complex agent/session/workspace routing beyond one target pet per MCP process.
- Remote HTTP/TCP MCP server.
- Advanced animation engine.
## User-visible/manual outcome
After this phase:
- Running `@open-pets/mcp` without `--pet` acquires a lease for the desktop default pet and tools affect the default pet.
- Running `@open-pets/mcp --pet <installed-non-default-id>` opens that installed pet as a temporary agent pet and tools affect that pet.
- Quitting the MCP process releases the lease.
- A temporary explicit non-default pet closes after the last active lease using it ends.
- If MCP crashes, desktop TTL cleanup expires the orphaned lease and closes the temporary pet.
- If `--pet` is missing or invalid/uninstalled, desktop falls back to the default pet and `openpets_status` reports the fallback clearly.
## Acceptance criteria
- Desktop local IPC adds internal methods:
- `lease.acquire`
- `lease.heartbeat`
- `lease.release`
- lease-scoped `pet.react`
- lease-scoped `pet.say`
- `@open-pets/client` exposes typed helpers for acquiring, heartbeating, releasing, status, say, and react with optional lease id.
- `@open-pets/mcp` acquires a lease during startup after MCP server initialization, not on first tool call.
- `@open-pets/mcp --pet <id>` sends requested pet id to `lease.acquire`.
- `@open-pets/mcp` accepts a bounded raw `--pet` string and lets desktop validate/fallback; only values that exceed length/control-character limits are fatal CLI errors.
- Raw `--pet` CLI bound: UTF-8 byte length must be 1-128 bytes and must not contain control characters, NUL, path separators, or whitespace-only content. Values that pass this bound but are not safe pet ids are sent to desktop and become `invalid_pet_id` fallback.
- If `--pet` is omitted, lease acquisition targets the current desktop default pet.
- If explicit `--pet` is installed and not broken, desktop opens/shows that pet as a temporary explicit agent pet.
- If explicit `--pet` is missing, not installed, invalid, or broken, desktop falls back to built-in/current default pet and records fallback reason.
- `openpets_status` reports:
- `configuredPetId`
- `leaseId`
- `actualTargetPetId`
- `actualTargetPetName`
- `usingDefaultPet`
- `fallbackReason` when applicable
- `routingImplemented: true`
- `openpets_say` and `openpets_react` target the acquired lease's actual pet.
- If lease acquisition fails because OpenPets is unavailable, MCP remains alive in degraded mode and `openpets_status` reports unavailable clearly.
- In degraded mode, `openpets_say` and `openpets_react` return MCP tool errors without crashing the server.
- MCP sends heartbeat while alive for an acquired lease.
- MCP releases its lease on graceful shutdown paths where practical:
- `SIGINT`
- `SIGTERM`
- normal process exit path
- stdio transport close when exposed by SDK/transport
- Desktop expires stale leases by TTL if MCP crashes or release is not delivered.
- Default pet remains persistent and is never auto-closed due to MCP lease release/expiry.
- Explicit non-default pet windows close after the last active lease for that pet ends/expires.
- Multiple MCP processes using the same explicit non-default pet keep one pet window open until all their leases end.
- Multiple MCP processes using the default pet do not close the default pet on release.
- Temporary explicit pet windows use local installed `spritesheet.webp` data URLs only; no remote images.
- Temporary explicit pet windows are draggable and basic-positioned without needing final multi-monitor polish.
- Operation is local-only; no TCP/HTTP.
- Public MCP tool set remains exactly `openpets_status`, `openpets_say`, `openpets_react`.
- No public MCP tool can install/remove/default pets, edit settings, access filesystem, run shell commands, or expose generic IPC.
- Automated checks cover lease state transitions, fallback behavior, TTL cleanup logic, and MCP status mapping.
## Proposed files/directories
Expected changes:
```text
docs/phases/phase-06b-mcp-lease-lifecycle.md
apps/desktop/src/local-ipc-protocol.ts
apps/desktop/src/local-ipc.ts
apps/desktop/src/lease-manager.ts
apps/desktop/src/agent-pet-controller.ts
apps/desktop/src/pet-window.ts
apps/desktop/src/default-pet-controller.ts
apps/desktop/src/app-state.ts (only if helper accessors are needed)
apps/desktop/src/check-lease-manager.ts
packages/client/src/index.ts
packages/client/src/protocol.ts
packages/client/src/check-client-protocol.ts
packages/mcp/src/index.ts
packages/mcp/src/tools.ts
packages/mcp/src/check-mcp-contract.ts
```
Exact file split may change if implementation reveals a simpler structure.
## Technical approach
### Lease model
Desktop app owns lease state in memory only. Leases are not persisted.
Recommended in-memory shape:
```ts
interface PetLease {
leaseId: string;
requestedPetId?: string;
targetKind: "default" | "explicit";
actualPetId: string;
fallbackReason?: string;
acquiredAt: number;
lastHeartbeatAt: number;
expiresAt: number;
}
```
Initial timing:
```text
heartbeat interval from MCP: 5 seconds
desktop lease TTL: 15 seconds
desktop cleanup interval: 5 seconds
```
Use generated random lease ids from desktop app. Do not trust client-provided lease ids for acquisition.
Target-kind rules:
- `targetKind: "default"` means tools route to the persistent default pet controller and release/expiry must never close the default pet.
- `targetKind: "explicit"` means tools route to a temporary explicit non-default pet window and release/expiry participates in explicit pet refcount cleanup.
- Fallback leases use `targetKind: "default"` even when the user requested an explicit pet.
- If the desktop default changes after a `targetKind: "default"` lease is acquired, that lease continues to target the live default controller. Status should report the live current default pet as actual target.
### Desktop target resolution
`lease.acquire` params:
```json
{
"requestedPetId": "snoopy"
}
```
Resolution rules:
1. If no `requestedPetId`, actual pet is current default pet.
2. If requested pet is `builtin` or equals current default, actual pet is default pet and remains persistent. This check happens before installed-pet path helpers that reject `builtin`.
3. If requested pet exists, is installed, is not broken, and is not built-in/default, actual pet is requested pet and its temporary window opens.
4. If requested pet is missing/broken/invalid, actual pet is current default pet, `targetKind` is `default`, fallback reason is recorded, and status reports fallback.
5. If current default is somehow missing/broken, use built-in fallback.
Fallback reasons must be short machine-readable codes, not raw exception text or local paths:
```text
invalid_pet_id
pet_not_installed
pet_broken
default_broken_fallback_builtin
```
### IPC request/result contracts
`lease.acquire` result includes:
```json
{
"leaseId": "random-id",
"requestedPetId": "snoopy",
"targetKind": "explicit",
"actualTargetPetId": "snoopy",
"actualTargetPetName": "Snoopy",
"usingDefaultPet": false,
"fallbackReason": null,
"expiresAt": 1234567890
}
```
`lease.heartbeat` accepts `{ "leaseId": "random-id" }` and returns `{ "leaseId": "random-id", "expiresAt": 1234567890 }`. Unknown/expired lease heartbeat returns structured `unknown_lease` error and MCP marks context degraded/stale.
`lease.release` accepts `{ "leaseId": "random-id" }` and returns `{ "released": true }`. Release is idempotent for shutdown races; unknown/already-released leases return `{ "released": false }`.
`status` may accept `{ "leaseId": "random-id" }`. Known leases return target details. Unknown/expired leases return degraded/stale lease status without raw local errors.
Stale status shape:
```json
{
"ok": false,
"appRunning": true,
"leaseId": "random-id",
"leaseActive": false,
"staleReason": "unknown_lease"
}
```
### Pet windows
Current default pet window remains controlled by `default-pet-controller.ts`.
Add a separate agent/leased pet controller for explicit non-default pets:
- One `BrowserWindow` per explicit non-default pet id.
- Window uses a safe rendering helper that takes an explicit pet id and reads only local installed `spritesheet.webp` as a data URL.
- Initial position offsets from the default pet/primary work area so temporary pets do not fully overlap.
- Windows are always-on-top/frameless/transparent like default pet.
- Temporary pet windows use `showInactive` / non-focus-stealing behavior like the default pet.
- Position persistence for temporary agent pets is not required in Phase 06B.
- Close temporary window when no active lease targets that pet.
Avoid storing remote URLs or absolute paths in durable state.
### Local IPC protocol
Extend Phase 05 request methods:
```text
lease.acquire
lease.heartbeat
lease.release
pet.react
pet.say
```
`pet.react` and `pet.say` should accept optional `leaseId`; without a lease id they keep Phase 05 default-pet behavior for backward compatibility.
Validation:
- Lease id must be a known active lease for lease-scoped say/react.
- Unknown/expired lease id returns structured error.
- Say/reaction safety validation remains enforced in desktop IPC.
- Request byte limits/token/version validation remain unchanged.
### Client package
Add typed client helpers:
```ts
client.acquireLease({ requestedPetId? })
client.heartbeatLease(leaseId)
client.releaseLease(leaseId)
client.status({ leaseId? })
client.react(reaction, { leaseId? })
client.say(message, { reaction?, leaseId? })
```
Keep existing Phase 05/06A call signatures backward-compatible where practical.
### MCP package
MCP startup flow:
1. Parse CLI args.
2. Create MCP server and register tools.
3. Start a shared lease-initialization promise before or during server connection.
4. Connect stdio transport.
5. Tool handlers await the shared lease-initialization promise before using lease state, so tools cannot race ahead of acquisition.
6. If acquire succeeds, store lease context and start heartbeat timer.
7. If acquire fails, remain in degraded mode.
Tool behavior:
- `openpets_status` reports lease/degraded state.
- `openpets_say`/`openpets_react` require an active lease to target explicit/default pet; if no lease, return `isError: true` with clear unavailable guidance.
Shutdown:
- Clear heartbeat timer.
- Best-effort `lease.release` if a lease exists.
- Close MCP server.
- Do not rely on async work in Node `exit`; signals and transport close are the practical release paths.
- Heartbeat timer should be `unref()` where available so it does not keep an otherwise-dead MCP process alive.
- Heartbeat failures must be caught; unknown lease responses mark context degraded/stale.
- Heartbeat timer should stop after unknown/stale lease to avoid repeated failures.
Do not write diagnostics to stdout.
### Auto-launch
Auto-launch of installed OpenPets desktop app remains deferred. It is important product behavior, but packaging/app discovery is not mature enough yet. Phase 06B focuses on lease lifecycle when the app is already running.
## Risks and tradeoffs
### Risk: phase grows into multi-pet architecture refactor
Mitigation:
- Keep Phase 06B to temporary explicit pet windows only.
- Do not implement user-pinned non-default pets beyond leases.
- Do not add persistent positions for temporary pets.
- Split again if full multi-pet manager changes become necessary.
### Risk: orphaned windows after MCP crash
Mitigation:
- Desktop TTL cleanup is mandatory.
- MCP release is best-effort, not the only cleanup path.
### Risk: wrong pet targeted
Mitigation:
- Desktop owns requested→actual resolution.
- `openpets_status` reports requested/configured and actual target.
- Missing/broken explicit pets fall back to default with reason.
- `targetKind` separates persistent default-controller routing from explicit temporary pet-window routing.
### Risk: privacy/spam through lease-scoped speech
Mitigation:
- Reuse existing `pet.say` validation and speech settings.
- Keep transient message replacement behavior; no unbounded queue.
## Security/privacy notes
- Local-only IPC remains token/version protected.
- Lease ids are random, app-generated, and in-memory only.
- Fallback reasons are sanitized machine-readable codes only.
- No remote API.
- No shell execution.
- No pet installation/removal/default mutation exposed to agents.
- Speech safety rules still apply.
- No durable agent session state is persisted.
## Test/check plan
Automated checks:
```bash
pnpm check
pnpm --filter @open-pets/desktop check
pnpm --filter @open-pets/client check
pnpm --filter @open-pets/mcp check
```
Phase 06B should add checks for:
- Lease acquire default target.
- Lease acquire explicit installed target.
- Invalid CLI `--pet` string is passed to desktop fallback path when bounded.
- Missing/broken requested pet fallback.
- Heartbeat extends expiry.
- Heartbeat unknown lease returns structured stale/degraded behavior.
- Release removes lease.
- Release is idempotent for unknown/already-released lease.
- TTL cleanup expires stale lease.
- Status for expired lease reports stale/degraded behavior.
- Status/errors do not leak local paths/tokens/raw filesystem messages.
- Last lease release/expiry closes explicit temporary pet controller state.
- Multiple leases for same explicit pet keep that window until the final lease ends.
- Default pet lease release/expiry does not close persistent default pet.
- Unknown/expired lease id rejects lease-scoped say/react.
- MCP status maps configured/requested/actual/fallback/routing fields correctly.
- MCP say/react checks prove handlers wait for lease init and pass `leaseId` to the client.
## Manual verification guide
1. Start desktop:
```bash
pnpm --filter @open-pets/desktop dev
```
2. Ensure at least one extra pet is installed from Pet Manager, for example `snoopy`.
3. Run MCP without `--pet` in an MCP inspector/client:
```bash
node packages/mcp/dist/index.js
```
4. Call `openpets_status`; confirm it reports the current default pet as actual target and `routingImplemented: true`.
5. Call `openpets_react` / `openpets_say`; confirm the default pet reacts/says.
6. Run MCP with explicit installed pet:
```bash
node packages/mcp/dist/index.js --pet snoopy
```
7. Confirm Snoopy opens as a separate temporary pet window and status reports actual target `snoopy`.
8. Call `openpets_react` / `openpets_say`; confirm Snoopy reacts/says, not the default pet.
9. Stop the MCP process; confirm Snoopy closes after release/cleanup while the default pet remains.
10. Start two MCP processes for the same explicit pet; confirm closing one keeps the temp pet open and closing both closes it.
11. Hard-kill/abruptly close one MCP process for an explicit pet; wait for TTL cleanup and confirm the temp pet closes.
12. Run MCP with missing pet:
```bash
node packages/mcp/dist/index.js --pet does-not-exist
```
13. Confirm status reports fallback to default pet and tools affect the default pet.
14. Run MCP with malformed-but-bounded pet input and confirm desktop fallback is reported rather than exposing local errors.
Manual acceptance question:
```text
Does Phase 06B pass on your machine: MCP startup acquires leases, --pet opens an installed non-default temporary pet, tools target the leased pet, missing pets fall back clearly, and release/TTL cleanup closes only temporary explicit pets?
```
## Oracle plan review
Oracle reviewed the initial Phase 06B plan and approved the overall architecture, but blocked implementation until `--pet` fallback behavior and default-versus-explicit lease targeting were sharpened.
## Oracle feedback disposition
- Fixed: `@open-pets/mcp` should accept bounded raw `--pet` strings and let desktop validate/fallback, so invalid/uninstalled requested pets can follow product fallback behavior.
- Fixed: Added `targetKind: "default" | "explicit"` to distinguish persistent default-controller leases from explicit temporary pet-window leases.
- Fixed: Defined IPC/result contracts for acquire, heartbeat, release, and lease-scoped status.
- Fixed: Defined unknown heartbeat, idempotent release, and expired lease status behavior.
- Fixed: Required lease-initialization promise so tools cannot race ahead of startup acquisition.
- Fixed: Required heartbeat timer `unref()`, caught heartbeat failures, stale/degraded marking, and no reliance on async `exit` handlers.
- Fixed: Required sanitized machine-readable fallback reason codes only.
- Fixed: Required explicit-pet rendering helper that reads only local installed `spritesheet.webp` for a given pet id.
- Fixed: Expanded automated and manual tests for invalid `--pet`, release idempotency, unknown heartbeat, expired status, leakage prevention, multi-lease refcount, crash TTL cleanup, and default-pet persistence.
- Fixed: Defined exact raw `--pet` bounds and control/path/whitespace rejection before desktop fallback.
- Fixed: Defined stale `status({ leaseId })` response shape.
- Fixed: Required built-in/default requested ids to be special-cased before installed-pet path validation.
- Fixed: Clarified default-target leases report the live current default controller target if default changes after acquisition.
- Fixed: Required MCP say/react checks proving lease-init waiting and lease id forwarding.
- Fixed: Required temporary pet windows to use non-focus-stealing `showInactive` behavior.
- Fixed: Required heartbeat timer to stop after unknown/stale lease.
Implementation review disposition:
- Fixed: `LeaseManager.heartbeat()` and `get()` now reject/remove expired leases before cleanup can revive or use them.
- Fixed: MCP status now preserves stale/unknown lease state instead of reporting cached active leases.
- Fixed: MCP degraded reasons used in tool errors are sanitized before returning to agents.
- Fixed: Explicit leases preserve `targetKind: "explicit"` for their lifetime instead of downcasting if the pet later becomes default.
- Fixed: Explicit pet say/react respect paused and speech-disabled behavior through IPC before updating temporary windows.
- Fixed: Explicit pet rendering errors are caught/logged to avoid unhandled rejections.
- Fixed: `default_broken_fallback_builtin` fallback reason is emitted when the configured default is unusable.
- Fixed: Raw `--pet` bounds reject all control characters.
- Deferred: Additional transport-close release handling beyond signals remains useful, but current release plus desktop TTL cleanup is sufficient for Phase 06B manual verification.
- Fixed: MCP status prefers live desktop lease status fields over cached acquire-time lease data when available.
- Fixed: Unavailable/degraded MCP status still reports `routingImplemented: true` for Phase 06B.
- Fixed: MCP help text now describes real `--pet` routing and fallback instead of stale Phase 06A behavior.
- Fixed: Added best-effort release on stdio transport close in addition to signal handling.

View file

@ -0,0 +1,330 @@
# Phase 07: Claude Code Detection and MCP Configuration
## Goal
Implement the first real Agent Setup experience for Claude Code: detect whether Claude Code appears available, show current OpenPets MCP configuration status, preview the exact MCP configuration command/file shape, and apply/remove a safe MCP configuration only after explicit user confirmation.
This phase makes Claude Code usable with the Phase 06 MCP lease routing path. It does not implement Claude hooks yet.
## Non-goals
- No Claude hook install, uninstall, or hook event handling. That belongs in Phase 08.
- No Cursor, VS Code, Windsurf, OpenCode, or Antigravity configuration.
- No automatic silent edits during onboarding or app launch.
- No agent-managed pet installation/removal/default-pet changes.
- No attempt to parse or preserve every undocumented field in Claude's internal `~/.claude.json`; prefer official CLI commands for writes.
- No auto-launch of Claude Code itself.
## User-visible/manual outcome
From the tray menu, **Configure Agents...** opens a real Claude Code setup window instead of a placeholder. The user can:
- See Claude Code as detected, not detected, configured, needs setup, or error.
- See the OpenPets MCP command that will be configured.
- Choose default-pet routing or an explicit installed pet for the Claude MCP server.
- Run a doctor/check to see actionable status.
- Click Configure only after seeing the planned change.
- Remove the OpenPets MCP entry if it was configured by OpenPets.
## Acceptance criteria
- Agent Setup window has a Claude Code card with clear status:
- `Detected`
- `Not detected`
- `Configured`
- `Needs setup`
- `Error / needs attention`
- Detection is best-effort and non-invasive:
- Check for a usable `claude` binary on `PATH` via `claude --version` with a short timeout.
- Check MCP status using `claude mcp list` with a short timeout when Claude is available.
- If Claude is missing or commands fail, report actionable text without crashing the app.
- Configuration target for Phase 07 is Claude Code user scope using the official CLI:
- `claude mcp add --scope user openpets -- npx -y @open-pets/mcp`
- With explicit pet: `claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet <petId>`
- Preview shows the exact command and the equivalent intended MCP JSON shape before applying.
- Configure requires an explicit button click in the UI.
- Removal requires an explicit button click in the UI and uses:
- `claude mcp remove --scope user openpets`
- Remove is enabled only when Claude reports an `openpets` MCP entry. If the entry cannot be verified as OpenPets-managed, the UI must warn that it will remove any Claude MCP server named `openpets`.
- Config operations are conservative and idempotent where Claude exposes enough detail:
- If Claude reports an `openpets` MCP entry and a detail command/output lets OpenPets verify the command/args match, Configure reports already configured / no change.
- If Claude only reports `openpets` as present but does not expose reliable command/arg detail, Configure reports `Configured / needs manual verification` rather than replacing it.
- If an `openpets` MCP entry exists but differs or cannot be verified, OpenPets must not automatically remove it. The UI must show a separate Replace action with a strong warning before remove-then-add.
- If Replace removes an entry but add fails, OpenPets shows a clear failure and the action journal contains the previous detected summary and intended restore command; no OAuth/session config restoration is attempted.
- Backups/restore behavior is defined and implemented for the files OpenPets directly edits.
- Because Phase 07 writes via the Claude CLI rather than directly editing `~/.claude.json`, OpenPets should not create a misleading full backup of that internal file by default.
- OpenPets records a local configuration action journal with timestamp, command preview, selected pet, and previous detected status where available.
- Restore behavior for Phase 07 is removal of the OpenPets MCP server entry through `claude mcp remove --scope user openpets`; it does not attempt to restore OAuth/session internals.
- Doctor/check reports:
- Whether `claude` is found.
- Whether `claude --version` works.
- Whether `claude mcp list` works.
- Whether an `openpets` MCP entry appears present.
- What command OpenPets expects.
- That Claude Code may need restart/reload for MCP changes to take effect.
- UI is CSP-safe and uses Electron IPC handlers with sender checks.
- Renderer/main IPC is narrow: renderer sends only action names and `{ selectedPetId?: string }`; main constructs argv, revalidates selected pet against installed non-broken pets, and enforces sender checks for the Agent Setup window.
- Configure/remove/replace operations are serialized so concurrent clicks cannot run overlapping Claude CLI commands.
- Automated checks cover command construction, pet id argument handling, status parsing, and timeout/error classification.
- `pnpm check` passes.
## Proposed files/directories
- `packages/claude/src/index.ts`
- Export Claude Code setup/detection helpers.
- `packages/claude/src/claude-code.ts`
- `detectClaudeCode`, `buildClaudeMcpAddCommand`, `buildClaudeMcpRemoveCommand`, `parseClaudeMcpList`, status types.
- `packages/claude/src/check-claude-code.ts`
- Node-based contract checks for command construction/status parsing.
- `packages/claude/package.json`
- Include build/check script updates.
- `apps/desktop/src/agent-setup.ts`
- Desktop orchestration for running Claude commands, timeouts, and local action journal.
- `apps/desktop/src/windows.ts`
- Replace Agent Setup placeholder with real Claude setup UI and preload access.
- `apps/desktop/preload.cjs`
- Expose narrow `openpetsAgentSetup` methods to renderer, separate from broader app/window APIs.
- `apps/desktop/src/app-state.ts` or adjacent state helper
- Persist lightweight agent setup status/action journal if needed.
- `docs/phases/phase-07-claude-detection-configuration.md`
## Technical approach
### Scope: Claude Code user-scope MCP only
Phase 07 configures the universal MCP path already built in Phase 06:
```text
Claude Code → @open-pets/mcp → @open-pets/client → desktop IPC → pet lease
```
Use a single MCP server name:
```text
openpets
```
Use user scope first because OpenPets is a personal companion integration and should not silently create team/project files. Project-scoped `.mcp.json` and per-project pet choices can be documented later.
### Detection
Detection runs from the desktop main process, never from the sandboxed renderer.
Best-effort command sequence:
1. Resolve `claude` from `PATH` by running `claude --version`.
2. If `claude` is not found, try common platform locations only as non-invasive hints:
- macOS GUI apps may have a reduced `PATH`; include common Homebrew/npm paths such as `/opt/homebrew/bin`, `/usr/local/bin`, and inherited `PATH` entries.
- Windows may expose `claude.cmd`; command resolution should try `claude` and `claude.cmd` where appropriate.
3. Run `claude mcp list` if version works.
4. If an official/detail command is available and works (for example `claude mcp get openpets` or a future JSON output), use it to verify command/args. Otherwise treat list output as present/absent only.
5. Parse output conservatively for an `openpets` entry.
6. Return a structured result to the UI.
Each child process should:
- Have a short timeout, initially 3 seconds.
- Kill the child process on timeout and classify the result as timeout, not as a generic error.
- Capture bounded stdout/stderr.
- Avoid shell interpolation by using `spawn`/`execFile`-style argv arrays.
- Never include secrets in UI text.
### Command construction
Centralize command construction in `@open-pets/claude` so tests can verify it without Electron:
Default pet:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp
```
Explicit pet:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet snoopy
```
Removal:
```bash
claude mcp remove --scope user openpets
```
Pet id values come from installed OpenPets pet ids in app state, not free-form UI entry. This avoids quoting/shell-injection complexity and aligns with the product rule that agents do not install/remove pets.
### Preview
Before applying, show:
- Human summary.
- Exact argv-style command.
- Equivalent intended MCP JSON shape:
```json
{
"mcpServers": {
"openpets": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/mcp"]
}
}
}
```
With explicit pet:
```json
"args": ["-y", "@open-pets/mcp", "--pet", "snoopy"]
```
### Applying changes
Use Claude Code's official CLI rather than directly editing `~/.claude.json`:
- Configure new entry: `claude mcp add --scope user openpets -- ...`
- Update existing differing or unverifiable entry: only through a separate explicit Replace action with warning text.
- Remove OpenPets entry: `claude mcp remove --scope user openpets`.
If configure/remove commands fail before making changes, show the sanitized stderr/stdout summary and leave state unchanged.
Exception: Replace is a two-step remove-then-add flow. If remove succeeds but add fails, Claude config has already changed; the UI must state that the previous `openpets` entry was removed and show the intended restore/add command from the action journal.
The UI should include a **Copy command** fallback so users can apply configuration manually if Claude CLI invocation from the Electron app fails because of PATH, permissions, or shell environment differences.
### Action journal and restore
Because user-scope MCP config lives inside Claude's internal `~/.claude.json`, which may include OAuth/session/private state, Phase 07 should not copy the whole file into OpenPets backups by default.
Instead, persist an OpenPets-local action journal entry for each attempted configuration/removal:
- timestamp
- action: configure/update/replace/remove
- selected pet id or default
- command argv preview
- detected previous OpenPets status, if known
- success/failure and sanitized message
Location:
```text
<OpenPets userData>/agent-setup-actions.json
```
Journal entries must be bounded and sanitized:
- Keep only the latest 20 entries.
- Store argv arrays and OpenPets status labels.
- Store sanitized output summaries capped at 500 characters.
- Do not store raw Claude config files, OAuth/session data, full stdout/stderr, home-directory paths, tokens, or environment variables.
During implementation, verify current `claude mcp` CLI syntax against official docs/help output before wiring writes. If a detail/get command or output format is unavailable or unexpected, fail closed: classify the entry as present but unverifiable and require manual review/Replace rather than assuming ownership.
Restore path in Phase 07 is explicit uninstall/remove of the OpenPets MCP entry via Claude CLI.
### UI shape
Agent Setup can stay lightweight and inline-data-URL based for now, matching existing Pet Manager/Settings style:
- Header: Configure Agents
- Claude Code card
- Status badge and details
- Pet selection dropdown: Default pet + installed non-broken pets
- Preview box
- Buttons: Refresh / Doctor, Configure, Remove
- If an existing `openpets` MCP entry is present but unverifiable/different, show a separate Replace action with stronger warning copy instead of silently changing it.
- Copy command fallback.
- Note: restart Claude Code if MCP changes do not appear immediately.
## Risks and tradeoffs
- Claude Code config behavior can change. Mitigation: use official CLI commands where possible and keep parsing conservative.
- `claude mcp list` output may not be stable. Mitigation: use it for status hints only; failed parsing reports needs manual check rather than corrupting config.
- Existing user config could be lost if OpenPets blindly removes an `openpets` entry. Mitigation: no automatic replacement; require explicit Replace with warning and action journal.
- User-scope config is personal and may contain secrets. Mitigation: do not directly edit or back up full `~/.claude.json` in Phase 07.
- `npx -y @open-pets/mcp` may use a published package in real installs, while local dev uses workspace packages. Mitigation: Phase 07 config preview targets the final product command; manual dev verification can inspect command preview without requiring published package behavior.
- Windows may need command resolution differences. Mitigation: command discovery tries platform variants, uses argv arrays, and includes Windows samples in checks.
## Security/privacy notes
- No shell string execution for user-controlled values.
- Pet choice is constrained to installed pet ids from app state.
- Renderer gets only narrow IPC methods for agent setup.
- Main process revalidates selected pet id and never trusts renderer-supplied command/argv.
- Sanitize child-process output before showing it in UI; bound max displayed length.
- Do not expose Claude config file contents or OAuth/session material in UI or logs.
- No silent configuration changes; user must click Configure/Remove.
## Test/check plan
- `packages/claude` contract checks:
- command argv for default pet.
- command argv for explicit pet.
- remove argv.
- equivalent JSON preview.
- MCP list parsing for present/missing/error-ish samples.
- unverifiable existing entry classification.
- Windows/macOS command discovery/path sample helpers where factored.
- invalid/free-form pet id rejection when using helper directly.
- Desktop checks:
- Agent setup IPC renderer sender restrictions.
- Command result timeout/error classification helper if factored separately.
- Action journal redaction/bounds if factored separately.
- Workspace:
- `pnpm check`
## Manual verification guide
After implementation:
1. Run `pnpm check`.
2. Start desktop: `pnpm --filter @open-pets/desktop dev`.
3. Open tray → **Configure Agents...**.
4. Confirm Claude Code status appears and does not crash whether Claude is installed or not.
5. Choose default pet and inspect preview.
6. Choose an installed non-default pet and confirm preview adds `--pet <id>`.
7. Use Copy command to verify the manual fallback text is correct.
8. Click Configure only if you are comfortable modifying your Claude Code user MCP config.
9. Run `claude mcp list` separately and confirm `openpets` appears.
10. If using an unpublished local package, do not expect a real Claude session to load `npx -y @open-pets/mcp` until the package is available; treat command/config preview and `claude mcp list` as the Phase 07 verification target.
11. Restart/open Claude Code and confirm OpenPets MCP tools appear only if the package is published or your dev environment routes the package name locally.
12. Click Remove and confirm `claude mcp list` no longer shows `openpets`.
13. Optional safer test: create a fake `claude` executable earlier in `PATH` that records argv and returns sample `mcp list` output; launch the desktop app from that terminal so the modified `PATH` is inherited; use it to verify missing Claude, successful add, failed add, existing entry, replace warning, and remove flows without touching real Claude config.
Expected results:
- Detection and doctor statuses are actionable.
- Preview matches the command OpenPets runs.
- Configure/remove are explicit and idempotent.
- OpenPets does not edit Claude config silently.
## Oracle plan review
Reviewed. Oracle found the phase boundary and official-CLI direction sound, but flagged two blockers:
- Idempotency/update detection was underspecified because `claude mcp list` may not reliably prove command/args match.
- Remove-then-add could lose an existing user `openpets` config if add fails.
Oracle also requested clearer remove semantics, platform command discovery, stricter renderer/main IPC rules, action journal schema/location, operation locking/timeouts, safer manual verification, and not promising real MCP tools appear before package publishing/dev override exists.
## Oracle feedback disposition
Fixed:
- Weakened idempotency to verified-detail-only and present/absent otherwise.
- Added explicit Replace action for unverifiable/different existing entries; no automatic remove-then-add.
- Defined Remove warning semantics for unverifiable entries.
- Added macOS GUI PATH and Windows `claude.cmd` discovery considerations.
- Tightened renderer/main IPC boundary and main-process pet revalidation.
- Added action journal location/schema/redaction/bounds.
- Added `replace` to the action journal action schema and clarified Replace add-failure state.
- Added operation serialization and timeout kill behavior.
- Added Copy command fallback and safer fake-CLI/manual verification guidance.
- Clarified that real Claude MCP tools require published package or dev package routing.
Fixed after re-review:
- Clarified that Replace can change Claude config if remove succeeds and add fails, and must show restore/add guidance rather than claiming state is unchanged.
- Required implementation-time verification of current `claude mcp` CLI syntax/help and fail-closed behavior for unavailable/unexpected detail output.
- Clarified Agent Setup preload should be narrow/separate and fake-CLI testing requires launching desktop from a terminal with modified `PATH`.

View file

@ -0,0 +1,371 @@
# Phase 08: Claude Enhanced Hooks
## Goal
Make Claude Code feel polished by adding safe global Claude hooks that send automatic OpenPets reactions and short local speech through `@open-pets/claude` and `@open-pets/client`.
This phase builds on Phase 07's Claude Code detection/MCP configuration UI. Phase 08 should let the user install, doctor, and uninstall OpenPets-managed Claude hooks with explicit confirmation and backups.
## Non-goals
- No direct changes to Claude MCP configuration beyond what Phase 07 already provides.
- No Cursor, VS Code, Windsurf, OpenCode, or Antigravity hooks.
- No model-generated speech.
- No displaying user prompt text, code, logs, file paths, command text, transcript contents, or tool input in pet speech.
- No blocking/denying Claude actions. OpenPets hooks are decorative/status-only and should fail open.
- No shell scripts, Unix-only sockets, `nc`, `/tmp` socket paths, or Bun runtime dependency.
- No durable persistence of Claude session payloads or transcripts.
## User-visible/manual outcome
From **Configure Agents...**, the Claude Code card gains enhanced hook controls:
- Hook status: not installed / installed / needs update / error.
- Preview of the exact hook entries OpenPets will add to Claude user settings.
- Install hooks with explicit confirmation and backup.
- Doctor/check hooks.
- Uninstall OpenPets-managed hooks only.
In a real Claude Code session, the pet reacts automatically:
- User prompt submitted → thinking, occasional short thinking speech.
- Tool use → editing/running/testing reaction, usually silent.
- Permission request → waiting reaction + clear approval message.
- Notification → waiting/attention reaction, silent by default.
- Stop → success reaction + occasional short success speech.
- Stop failure → error reaction + occasional short error speech.
## Acceptance criteria
- `@open-pets/claude` exposes a Node/npm/npx-friendly hook entry point, for example:
- `open-pets-claude hook`
- final published/dev preview command may be displayed as `npx -y @open-pets/claude hook` until packaging decides global/local binary details.
- Hook command reads Claude hook JSON from stdin with a strict byte limit and timeout-friendly behavior.
- Hook command never blocks Claude intentionally:
- returns exit code 0 for handled, ignored, and degraded OpenPets-unavailable cases.
- only uses non-zero exit for unrecoverable CLI/runtime invocation errors before hook handling begins.
- Hook command stdout is always empty, including `UserPromptSubmit`, because Claude may ingest stdout as context.
- Installed hook entries must not synchronously block Claude for decorative status updates:
- use Claude hook `"async": true` where supported.
- keep timeout short, initially 3 seconds.
- if async hooks are not supported by the installed Claude version, doctor/install must warn and fail closed rather than installing cold-start `npx` hooks on high-frequency events.
- future packaging may replace `npx` with a faster local/bundled binary, but Phase 08 should not rely on synchronous `npx` for `PreToolUse`.
- Hook command sends OpenPets events through `@open-pets/client` only; no direct socket/manual JSON writes.
- Hook mapping handles the selected Claude hook events and ignores all others safely:
- `UserPromptSubmit``thinking` + selective speech.
- `PreToolUse``editing`, `testing`, `running`, or `working` based on `tool_name` and safe classification of `tool_input`.
- `PermissionRequest``waiting` + fixed approval speech.
- `Notification``waiting` by default, no speech.
- `Stop``success` + selective speech.
- `StopFailure``error` + selective speech.
- If Claude event names differ on an installed Claude version, unknown events are ignored safely and surfaced in doctor output where possible.
- Speech uses local static message pools/templates only.
- Speech safety rules are enforced:
- max 140 characters.
- single-line only.
- no code blocks/code-like snippets.
- no URLs.
- no absolute/relative path-like content.
- no secret-looking values.
- never include raw user prompt/tool input/assistant response/transcript content.
- Speech is throttled/selective:
- no speech on `PreToolUse` or `Notification` by default.
- permission speech always allowed subject to a short cooldown to avoid duplicates.
- lifecycle speech has a cooldown, initially at least 20 seconds per category.
- throttle state is ephemeral and stored outside Claude settings, with bounded size and no payload content.
- Hooks route through the default OpenPets desktop route in Phase 08:
- Because Claude hooks do not reliably know the active MCP lease id, they call `react`/`say` without `leaseId` and therefore affect the desktop default pet.
- Future lease-aware Claude routing is explicitly deferred until a reliable session↔lease correlation is available.
- Global hook installation targets Claude Code user settings:
- default path `~/.claude/settings.json` only for Phase 08.
- do not honor `CLAUDE_CONFIG_DIR` unless a later phase verifies it is official/current Claude Code behavior.
- creates a timestamped backup before writing.
- validates settings is a JSON object before modifying.
- settings path must be missing or a regular file; abort on symlink/special file surprises.
- create parent directory with restrictive permissions where practical.
- write temp file with restrictive permissions where practical before atomic rename.
- preserves unrelated settings and unrelated hooks.
- installs only clearly OpenPets-managed command hooks.
- uninstall/update matching requires the `--openpets-managed` marker; package-name-only matching is not enough.
- idempotent reinstall/update.
- atomic write where practical.
- Uninstall removes only OpenPets-managed hook commands and leaves unrelated user/project hooks intact.
- Agent Setup UI shows hook status, preview, install/update, doctor, and uninstall controls with explicit warning that hooks execute commands from Claude Code, are global user-scope across Claude projects, and route to the OpenPets default pet in Phase 08.
- Agent Setup provides a local dev command toggle so manual testing can install Claude MCP/hooks against this checkout's built `dist` files instead of published `npx` packages:
- production MCP: `npx -y @open-pets/mcp ...`
- local MCP: `node <repo>/packages/mcp/dist/index.js ...`
- production hooks: `npx -y @open-pets/claude hook --openpets-managed`
- local hooks: `node <repo>/packages/claude/dist/cli.js hook --openpets-managed`
- Automated checks cover hook payload parsing, event mapping, speech safety, throttling decisions, settings merge/uninstall behavior, and command preview.
- `pnpm check` passes.
## Proposed files/directories
- `packages/claude/package.json`
- Add `bin` for `open-pets-claude`.
- Ensure check builds and validates hooks.
- `packages/claude/src/cli.ts`
- CLI dispatcher for `hook`, `doctor-hooks`, maybe install/uninstall helpers used by desktop/tests.
- `packages/claude/src/hooks.ts`
- Hook payload parsing, event mapping, client calls, speech selection/throttling.
- `packages/claude/src/hook-messages.ts`
- Static local speech pools.
- `packages/claude/src/hook-settings.ts`
- Claude settings path resolution, backup, merge, preview, uninstall, doctor helpers.
- `packages/claude/src/check-claude-hooks.ts`
- Contract checks.
- `packages/claude/src/index.ts`
- Export hook helpers for desktop.
- `apps/desktop/src/agent-setup.ts`
- Add hook status/preview/install/doctor/uninstall orchestration and action journal entries.
- `apps/desktop/src/windows.ts`
- Add hook section to Claude Code card.
- `apps/desktop/preload.cjs`
- Add narrow hook actions through `openpetsAgentSetup`.
- `docs/phases/phase-08-claude-enhanced-hooks.md`
## Technical approach
### Hook command behavior
Claude Code command hooks receive JSON on stdin. The OpenPets hook command should:
1. Read stdin up to a strict limit, initially 64 KiB.
2. Parse JSON defensively.
3. Determine `hook_event_name` and safe metadata such as `tool_name`.
4. Map to an OpenPets reaction and optional static speech.
5. Send via `createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 })`.
6. Swallow OpenPets-unavailable errors and exit 0.
The command should write debug output only to stderr when `OPENPETS_DEBUG=1`; stdout must stay empty so Claude does not receive unintended context.
### Event mapping
Initial mapping:
```text
UserPromptSubmit → react thinking; maybe say thinking pool
PreToolUse Edit → react editing
PreToolUse Write → react editing
PreToolUse MultiEdit → react editing
PreToolUse Bash with test-ish command → react testing
PreToolUse Bash otherwise → react running
PreToolUse other tool → react working
PermissionRequest → say "Approval needed" with waiting reaction
Notification → react waiting; no speech
Stop → say success pool with success reaction
StopFailure → say error pool with error reaction
unknown → no-op
```
Tool input is used only for classification, never copied into speech.
### Speech pools
Use local, short, safe strings. Example categories:
```text
thinking: "Thinking it through", "Let me check", "On it"
success: "Done", "That worked", "All set"
error: "Something failed", "Needs another look", "Hit a snag"
permission: "Approval needed"
```
Selection should be deterministic enough for tests but varied at runtime, for example injectable random source in tests.
### Throttling
Throttle state should not include hook payload content. Store only timestamps/counters by category in an OpenPets-owned file, for example:
Use a pure Node cross-platform path because the hook runs outside Electron:
```text
macOS/Linux: ${XDG_STATE_HOME:-~/.local/state}/openpets/claude-hook-throttle.json
Windows: %LOCALAPPDATA%\OpenPets\claude-hook-throttle.json
fallback: os.tmpdir()/openpets-<uid>/claude-hook-throttle.json
```
Requirements:
- Bounded JSON object.
- Atomic best-effort write.
- Safe if missing/corrupt.
- No hard failure if throttle storage cannot be read/written.
### Settings install/update/uninstall
Use Claude Code user settings:
```text
~/.claude/settings.json
```
Hook config shape should follow current Claude docs:
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "npx -y @open-pets/claude hook --openpets-managed",
"timeout": 3,
"async": true,
"asyncRewake": false
}
]
}
]
}
}
```
Use one OpenPets-managed command per event with the stable `--openpets-managed` marker in the command so uninstall can safely identify owned hooks.
Install algorithm:
1. Resolve settings path to `~/.claude/settings.json`.
2. Read existing file if present; missing file becomes `{}`.
3. Refuse symlink/special-file settings paths; allow missing or regular file only.
4. Validate top-level JSON object.
5. Validate `hooks` is absent or object; if malformed, abort and ask user to fix manually.
6. Verify Claude hook async support from current docs/known behavior and local checks where practical. If no reliable local check exists, install may proceed based on documented support but doctor must warn when support is uncertain.
7. Create backup before write: `settings.json.openpets-backup-YYYYMMDD-HHMMSS.json`.
8. Remove previous OpenPets-managed hook commands.
9. Add current OpenPets-managed async hook entries.
10. Atomic write temp file + rename.
Uninstall algorithm:
1. Resolve/read/validate settings.
2. Backup before write.
3. Remove only hook commands containing the `--openpets-managed` marker.
4. Prune empty arrays/objects created by OpenPets where safe.
5. Atomic write.
Doctor should report:
- settings path.
- whether file exists and parses.
- whether hooks object is valid.
- installed/up-to-date/needs update/not installed.
- backup path from last install/uninstall if applicable.
- whether OpenPets desktop IPC is reachable.
- reminder that Claude hooks require trusted workspaces and Claude restart/reload if settings were changed.
### Agent Setup UI
Add an **Enhanced Claude hooks** section to the existing Claude Code card:
- Status badge.
- Preview JSON snippet.
- Buttons: Doctor hooks, Install/Update hooks, Uninstall hooks.
- Warning copy: Claude hooks execute command hooks automatically; OpenPets-managed hooks are status-only, fail open, and do not inspect/store code content.
- Local dev command toggle shared with the MCP setup preview/actions.
## Risks and tradeoffs
- Claude hook schemas/events can change. Mitigation: parse defensively, ignore unknowns, document event contract, keep doctor actionable.
- Hooks are command execution. Mitigation: explicit confirmation, preview, OpenPets marker, uninstall, backups, no silent edits.
- Direct settings edits can corrupt user config if careless. Mitigation: validate JSON object, backup, atomic writes, preserve unrelated keys, abort on malformed hooks.
- `npx -y @open-pets/claude hook` may be slow for frequent hooks. Mitigation: no speech on high-frequency tool events; hook timeout 3 seconds; future packaging can switch command to bundled/local binary.
- Hooks may be skipped in untrusted Claude workspaces. Mitigation: doctor/manual guide documents trust requirement.
- Session-to-MCP-lease routing is ambiguous. Mitigation: Phase 08 explicitly routes hook events to default pet; MCP per-project pet routing remains available through tools/leases.
## Security/privacy notes
- Never include raw Claude payload content in speech.
- Do not read transcript files.
- Do not store hook payloads, prompts, tool inputs, assistant messages, cwd, transcript paths, or session ids in durable logs.
- Debug logs, if enabled, must be minimal and sanitized.
- Hook stdout must stay empty; use stderr for debug only when `OPENPETS_DEBUG=1`.
- Hook commands must fail open and avoid blocking user work.
- Settings installer must preserve unrelated hooks and avoid removing anything not OpenPets-managed.
- Hook settings must be async/background; do not install synchronous cold-start `npx` hooks for high-frequency events.
- Speech safety filters should reuse or match MCP speech safety rules.
## Test/check plan
- `packages/claude` checks:
- hook event mapping fixtures for each accepted event.
- malformed/oversized stdin handling.
- speech safety rejects code/path/url/secret/multiline/overlength content.
- throttling allows/blocks expected categories without storing payload content.
- settings install preview/merge preserves unrelated settings and hooks.
- settings reinstall is idempotent.
- settings uninstall removes only OpenPets-managed hooks.
- malformed settings/hook object aborts safely.
- symlink/special-file settings path aborts safely.
- generated hook config includes `--openpets-managed`, `timeout: 3`, and `async: true`.
- published/local command previews for MCP and hooks.
- Desktop checks:
- Agent Setup hook action validation/sender restrictions where practical.
- Workspace:
- `pnpm check`
## Manual verification guide
After implementation:
1. Run `pnpm check`.
2. Start desktop: `pnpm --filter @open-pets/desktop dev`.
3. Open tray → **Configure Agents...**.
4. In Claude Code, confirm MCP is configured from Phase 07 or configure it now.
5. Inspect Enhanced Claude hooks preview.
For local checkout testing, enable **Use local dev commands** and confirm previews use `node .../packages/mcp/dist/index.js` and `node .../packages/claude/dist/cli.js` instead of `npx`.
Local dev mode writes absolute checkout paths into Claude config; uninstall or replace it before moving/deleting the checkout.
6. Click Doctor hooks and confirm it reports settings status clearly.
7. Click Install/Update hooks only if okay modifying `~/.claude/settings.json`.
8. Confirm a backup file was created next to Claude settings.
9. Confirm malformed settings are reported safely by temporarily testing against a fake settings path in checks or a controlled test profile, not by corrupting your real settings.
10. Confirm unrelated hook entries remain after install/update.
11. Confirm reinstall is idempotent.
12. Start/restart Claude Code in a trusted workspace.
13. Invoke a hook command with a stdin fixture directly, for example a `UserPromptSubmit` fixture, and confirm it exits 0 with empty stdout even if OpenPets desktop is unavailable.
Example after build: `printf '{"hook_event_name":"UserPromptSubmit","prompt":"hello"}' | node packages/claude/dist/cli.js hook --openpets-managed`.
14. Submit a real prompt and confirm the pet reacts thinking with occasional safe speech.
15. Run/edit/test from Claude and confirm reactions change without noisy speech.
16. Trigger a permission request and confirm waiting + approval message.
17. Finish a response and confirm success speech/reaction.
18. If possible, trigger a failing turn and confirm error reaction/speech.
19. Quit OpenPets desktop and confirm Claude hooks do not break Claude Code.
20. Return to Agent Setup and click Uninstall hooks.
21. Confirm OpenPets-managed hook entries are removed, uninstall is idempotent, and unrelated Claude settings remain.
Expected results:
- Hooks are explicit, reversible, and backed up.
- Backups may contain user-sensitive Claude settings; keep them local/private.
- Pet reactions feel automatic and non-spammy.
- Speech is short, local, safe, and never includes private prompt/tool content.
- Broken/missing OpenPets desktop app does not break Claude Code.
## Oracle plan review
Reviewed. Oracle found the phase boundary sound but flagged one blocker:
- Decorative hooks cannot rely on synchronous cold-start `npx`, especially for frequent `PreToolUse`; hook entries must be async/background or use a fast local binary.
Oracle also requested: marker in examples, no `CLAUDE_CONFIG_DIR` until verified, UI warnings about global/default-pet routing, pure Node throttle path, regular-file/no-symlink settings hardening, selected-events wording, and expanded manual verification.
## Oracle feedback disposition
Fixed:
- Required `async: true` hook entries with short timeout and fail-closed install if async support is unavailable/uncertain.
- Added `--openpets-managed` marker to preview/config examples.
- Removed `CLAUDE_CONFIG_DIR` support from Phase 08 scope.
- Added UI warning requirement for global user-scope hooks and Phase 08 default-pet routing.
- Defined pure Node cross-platform throttle-state path.
- Added settings path hardening: missing/regular file only, abort symlink/special files, restrictive permissions where practical.
- Clarified Phase 08 handles selected Claude events and ignores others safely.
- Expanded tests/manual verification for malformed settings, unrelated hooks preservation, idempotency, unavailable desktop, stdin fixture invocation, and uninstall idempotency.
Fixed after re-review:
- Required empty hook stdout for all events.
- Clarified async support verification may rely on documented support with doctor warning if no reliable local check exists.
- Tightened uninstall/update matching to require `--openpets-managed` marker.
- Added `asyncRewake: false` to generated config, stdin fixture command example, and backup privacy note.

View file

@ -0,0 +1,192 @@
# Phase 08C: Test Harness and Regression Pack
## Goal
Add a lightweight, explicit test workflow for OpenPets before Phase 09 onboarding glues together pet management, MCP routing, Claude setup, and hooks.
The project already has useful `check-*.ts` contract checks. This phase standardizes how those checks are run, adds a top-level `pnpm test`, and expands regression coverage around the riskiest recent features without introducing a heavy test stack prematurely.
## Non-goals
- No full Electron UI automation yet.
- No Playwright/Spectron-style end-to-end suite.
- No large migration to a test framework unless a clear gap appears.
- No broad refactors of feature code solely to satisfy tests.
- No network-dependent tests.
- No tests that mutate real Claude user settings, real pet installs, or real desktop app state.
## User-visible/manual outcome
Developers can run:
```bash
pnpm test
pnpm check
```
and get clear regression coverage for core OpenPets behavior. `pnpm check` remains the pre-commit/phase gate; `pnpm test` becomes the focused regression test command.
## Acceptance criteria
- Root package has a `test` script.
- These packages get `test` scripts in this phase because they already have meaningful contract checks:
- `apps/desktop`
- `packages/client`
- `packages/mcp`
- `packages/claude`
- Packages without meaningful runtime checks yet may keep `test` as type/build-only or omit it with a documented reason:
- `packages/cli`
- `packages/pet-format`
- Existing `check-*.ts` files are either:
- included in package `test`, or
- clearly kept in `check` with `test` delegating to them.
- Package tests run against fresh built artifacts. Either package `test` builds first, or root `pnpm test` builds before invoking tests. Avoid stale `dist` false positives.
- Prefer `check = typecheck + build + test` at each package so `check` cannot drift away from regression tests.
- `pnpm check` still passes.
- `pnpm test` passes.
- Tests are deterministic and do not require:
- a running Electron app.
- a real Claude installation.
- network access.
- writes to real `~/.claude/settings.json`.
- writes to real OpenPets user data, except via isolated temp directories or pure in-memory checks.
- Tests using temp directories clean them up and do not depend on the current working directory except explicit repo-relative fixture paths.
- Claude settings install/uninstall tests must pass explicit temp settings paths; they must never call install/uninstall against the default real Claude settings path.
- Regression coverage includes at least:
- MCP tool contract and safe speech validation.
- MCP lease status/fallback behavior where currently factored.
- Claude MCP command previews for published/local dev modes.
- Claude hook event mapping.
- Claude hook stdout/privacy/speech safety expectations.
- Claude settings install/update/uninstall on temp fixture files.
- Local dev command path validation failure cases where practical.
- Desktop lease manager behavior.
- Desktop IPC protocol validation.
- Pet zip safety/catalog validation checks.
- Test output names should make it obvious what failed.
- `docs/implementation-process.md` or the phase spec documents the new test/check distinction.
## Proposed files/directories
- `package.json`
- Add root `test` script.
- Workspace package `package.json` files:
- Add package-level `test` scripts where appropriate.
- Existing check files:
- `apps/desktop/src/check-*.ts`
- `packages/client/src/check-*.ts`
- `packages/mcp/src/check-*.ts`
- `packages/claude/src/check-*.ts`
- Optional new fixture/check files:
- `packages/claude/src/check-claude-local-dev.ts` if not folded into existing checks.
- `docs/testing.md`
- Short developer-facing description of `test` vs `check`, package expectations, and isolation rules.
- `docs/phases/phase-08c-test-harness-regression-pack.md`
## Technical approach
### Keep it simple first
Continue using Node-executed TypeScript-built contract checks for now:
```text
pnpm typecheck && pnpm build && node dist/check-*.js
```
Reasons:
- This matches the current repo pattern.
- It keeps dependencies minimal.
- The current risky logic is mostly pure Node contract logic, not browser UI.
- We can still add Vitest later when richer assertions/mocking become painful.
### Define command semantics
Recommended command meanings:
```text
pnpm test = deterministic regression tests only
pnpm check = typecheck + build + tests + static/smoke checks required before commits
```
Package `check` should call package `test` after typecheck/build when practical:
```text
check = typecheck + build + test
test = node dist/check-*.js ...
```
If a package needs `test` to build first for standalone use, keep that behavior explicit and avoid stale `dist` artifacts. The implementation should avoid duplicating long command lists more than necessary.
### Avoid real user config/state
Claude settings tests must use temp files/directories. Desktop state tests should use existing pure helpers or isolated temp paths only. If a test cannot avoid real Electron `app.getPath("userData")`, keep it out of `pnpm test` and document it as manual/Electron-only.
### Regression priorities
Add or strengthen tests around bugs found during manual verification:
- Local dev mode should not accidentally use published `npx` commands.
- Local hook command paths are shell-quoted safely.
- Local dev mode validates missing/invalid dist paths where practical.
- Hook commands produce empty stdout.
- Hook CLI direct invocation produces empty stdout for normal and malformed fixture payloads.
- Explicit pet rendering should not regress to giant installed-pet image data URLs where this can be checked without Electron.
- Benign IPC client disconnect errors should not fail response handling where factored.
## Risks and tradeoffs
- Adding a framework now may slow progress. Mitigation: standardize existing checks first.
- Pure Node checks cannot catch every Electron UI issue. Mitigation: keep manual verification for tray/windows and add UI automation later.
- Too many duplicated scripts can become hard to maintain. Mitigation: keep `test`/`check` scripts simple and consistent.
- Tests that touch real Claude/OpenPets state would be risky. Mitigation: temp fixtures only.
## Security/privacy notes
- Tests must not read or write real `~/.claude/settings.json`.
- Tests must not print secrets, local tokens, or full user config payloads.
- Fixtures should be synthetic and minimal.
- Any local path assertions should avoid requiring a specific user home path.
## Test/check plan
- Run `pnpm test`.
- Run `pnpm check`.
- Confirm no test writes to real Claude settings.
- Confirm temp fixtures are cleaned up.
- Confirm test failures are understandable by temporarily reviewing script names/output.
## Manual verification guide
After implementation:
1. Run:
```bash
pnpm test
pnpm check
```
2. Confirm both pass.
3. Confirm no real Claude settings were modified.
4. Confirm no desktop app launch is required for tests.
5. Confirm the command split is understandable:
- `test` = deterministic regression tests.
- `check` = full pre-commit validation.
## Oracle plan review
Reviewed. Oracle approved deferring Vitest and standardizing existing Node contract checks first.
Oracle requested clearer package scope, fresh built artifact behavior, temp-dir cleanup/isolation requirements, explicit no-real-Claude-settings guard, `check` calling `test` to prevent drift, and direct hook CLI stdout assertion.
## Oracle feedback disposition
Fixed:
- Listed packages expected to get `test` scripts now and packages allowed to defer.
- Required tests to run against fresh built artifacts and preferred `check = typecheck + build + test`.
- Added temp-dir cleanup/current-working-directory isolation acceptance criteria.
- Added explicit guard that Claude settings install/uninstall tests must use temp settings paths.
- Added `docs/testing.md` to proposed files.
- Added direct hook CLI stdout assertion to regression priorities.

View file

@ -0,0 +1,335 @@
# Phase 09: First-run Onboarding
## Goal
Connect the existing pet manager, agent setup, default pet, and settings pieces into a clear first-run onboarding flow.
Phase 09 should make a fresh OpenPets install feel ready: the default pet appears, the user can optionally browse/install pets, configure Claude from the real Agent Setup flow, and finish with onboarding completion persisted.
## Non-goals
- No new agent integrations beyond the existing Claude Code setup/hook controls.
- No new catalog install mechanics beyond the existing Pet Manager install flow.
- No packaging/release installer work.
- No account system, telemetry, analytics, or network calls beyond the existing catalog fetch.
- No forced pet install; the bundled default pet remains enough to complete onboarding.
- No silent edits to Claude or other agent settings; existing confirmation/backup behavior remains required.
- No destructive onboarding reset UI beyond developer/manual reset instructions for this phase.
## User-visible/manual outcome
On a fresh app state, OpenPets opens a first-run onboarding window after startup.
The onboarding window has a simple wizard shape:
1. **Welcome**
- Explains that OpenPets lives in the tray/menu bar.
- Confirms the bundled default pet is already available.
- Provides a Continue button.
2. **Pets**
- Shows the current default pet and a short explanation that installing more pets is optional.
- Provides an action to open the real Pet Manager.
- Provides Skip/Continue.
- If the catalog is unavailable, onboarding remains completable and points the user to try later from Pet Manager.
3. **Agents**
- Shows Claude Code as the supported first integration for this MVP checkpoint.
- Provides an action to open the real Agent Setup window.
- Explains that setup is confirmation-based and can be skipped.
4. **Ready**
- Confirms OpenPets is ready.
- Offers quick actions to open Pet Manager or Agent Setup.
- Finish persists onboarding completion and closes the onboarding window.
The tray remains usable during onboarding. Closing onboarding without finishing should not mark onboarding complete; it should be possible to reopen onboarding from the tray until completed.
## Acceptance criteria
- App state persists onboarding completion, for example `preferences.onboardingCompleted` or equivalent versioned state.
- State normalization handles missing/old state by treating onboarding as incomplete.
- Fresh app state opens onboarding automatically after app initialization.
- The default pet appears early when `openDefaultPetOnLaunch` is enabled, independent of onboarding completion.
- Onboarding is implemented as a normal sandboxed/context-isolated Electron task window with the existing preload/security posture.
- Onboarding uses dedicated IPC handlers with sender validation; only the onboarding renderer can complete onboarding.
- Onboarding IPC sender validation must use the main-process `taskWindows` / `webContents.id` mapping, not renderer-declared DOM attributes.
- If the shared preload exposes broad APIs, existing main-process IPC sender checks must deny pet install/remove, preference mutation, and agent setup mutation from the onboarding window.
- Onboarding cannot directly mutate pet installs or agent configuration except by opening the existing Pet Manager / Agent Setup flows.
- Onboarding includes actions to:
- continue through steps,
- open Pet Manager,
- open Agent Setup,
- finish onboarding,
- close without completing.
- Onboarding completion persists and prevents automatic reopening on the next app launch.
- Finishing onboarding closes the onboarding window and refreshes the tray immediately so **Continue Setup...** disappears without restart.
- Until onboarding is completed, the tray exposes a way to reopen onboarding, for example **Continue Setup...**.
- After onboarding is completed, the tray no longer shows the onboarding continuation item by default.
- Catalog failure does not block onboarding completion.
- Agent detection/configuration errors do not block onboarding completion.
- No automated test touches real Claude settings or real user data.
- Automated checks cover onboarding state normalization and completion persistence behavior.
- `pnpm test` passes.
- `pnpm check` passes.
## Proposed files/directories
- `apps/desktop/src/app-state.ts`
- Add persisted onboarding completion flag with backwards-compatible normalization.
- Add a narrow completion helper, e.g. `completeOnboarding()`.
- `apps/desktop/src/windows.ts`
- Add `onboarding` task window kind and HTML.
- Add IPC handlers for onboarding snapshot/completion and opening Pet Manager / Agent Setup from onboarding.
- Reuse existing CSP, sandbox, context isolation, navigation blocking, and sender validation patterns.
- `apps/desktop/src/main.ts`
- Decide when to open onboarding automatically after startup.
- `apps/desktop/src/tray.ts`
- Add **Continue Setup...** while onboarding is incomplete.
- Refresh menu after onboarding completion.
- `apps/desktop/preload.cjs`
- Expose a narrow `openpetsOnboarding` bridge only for onboarding actions.
- `apps/desktop/src/check-onboarding-state.ts`
- Contract checks for state migration/completion through extracted pure normalization/completion helpers or injected temp state paths.
- Do not import Electron-bound `app-state.ts` directly from plain Node tests if that couples tests to Electron `app.getPath`.
- `docs/phases/phase-09-first-run-onboarding.md`
## Technical approach
### State model
Extend the existing versioned app state with onboarding completion.
Preferred minimal shape:
```ts
preferences: {
defaultPetId: string;
openDefaultPetOnLaunch: boolean;
speechBubblesEnabled: boolean;
petScale: number;
onboardingCompleted: boolean;
}
```
Default/missing value should be `false`, so existing installs see onboarding once after upgrading. If that is too disruptive during development, the implementation may use a developer reset/manual test path, but the production behavior should remain first-run based.
Add narrow exported helpers:
```ts
isOnboardingCompleted(): boolean
completeOnboarding(): OpenPetsStateV1
```
`completeOnboarding()` should only set the completion flag and persist via existing atomic state write behavior.
### Startup behavior
Startup order should stay stable:
1. Initialize state.
2. Install internal UI handlers.
3. Create tray.
4. Install default pet display handlers.
5. Start local IPC.
6. Show default pet if preference says so.
7. Open onboarding if incomplete.
8. Refresh tray.
Opening onboarding should not delay local IPC startup or default pet display. If opening the window fails, log the error and keep the app usable from tray.
### Window and IPC
Add an `onboarding` task window kind rather than a separate dashboard architecture.
The onboarding renderer should be static inline HTML loaded through the same data URL pattern as existing task windows. It should use the existing preload bridge, not Node integration.
Suggested bridge:
```ts
window.openpetsOnboarding = {
getSnapshot(): Promise<{ defaultPetName: string; onboardingCompleted: boolean }>;
complete(): Promise<OpenPetsStateV1>;
openPetManager(): Promise<void>;
openAgentSetup(): Promise<void>;
}
```
Keep the snapshot lightweight: default pet/onboarding state only. Phase 09 does not need to duplicate the full Pet Manager or Agent Setup UIs inside onboarding; opening the real windows is enough and avoids parallel state/config logic. Do not expose Claude settings paths, Claude config previews, or detailed agent status in onboarding.
Sender validation should allow onboarding access only through the existing main-process window-kind mapping from `webContents.id` to `taskWindows`. Renderer-declared values like `data-openpets-view="onboarding"` may be useful for the preload bridge but must not be the trust boundary.
### Onboarding UI details
The UI should be intentionally small and clear, not a full dashboard:
- One card/wizard window, roughly the same size as other task windows.
- Step indicators for Welcome / Pets / Agents / Ready.
- Primary and secondary actions per step.
- Clear skip language: pet installs and agent setup can be done later.
- Explicit reminder that Claude setup may edit user-level Claude settings only after confirmation and backups.
### Reopen behavior
Before completion:
- App startup opens onboarding automatically.
- Tray shows **Continue Setup...**.
- Closing the window keeps completion false.
After completion:
- App startup does not auto-open onboarding.
- Tray omits **Continue Setup...**.
- Pet Manager, Agent Setup, and Settings remain available normally.
### Reset/manual testing behavior
Phase 09 does not need an end-user reset UI. Manual verification can reset onboarding by deleting the dev Electron user-data directory or editing/removing the dev state file documented by app logs.
If a small developer-only helper is cheaper and safer, it may be added only as a documented internal check command, not as product UI.
## Risks and tradeoffs
- **Onboarding could become a second implementation of Pet Manager/Agent Setup.** Avoid this by using onboarding as a guide that opens the real windows.
- **Existing dev installs may see onboarding after the state schema changes.** This is acceptable for first-run behavior; manual instructions should include how to mark complete or reset state.
- **Catalog/agent setup failures can make onboarding feel blocked.** The flow must make those steps optional and completable.
- **Window focus could be annoying at startup.** The default pet and tray remain primary; onboarding should be a normal window and not repeatedly refocus if closed.
- **State tests may be coupled to Electron `app`.** Prefer extracting pure normalization/completion helpers if needed instead of brittle Electron test setup.
## Security/privacy notes
- Onboarding must not silently configure agents or install pets.
- Claude setup remains routed through the existing Agent Setup controls with explicit confirmation and backups.
- No onboarding payload should include Claude config contents beyond what existing Agent Setup already exposes.
- Renderer stays sandboxed/context-isolated with no Node integration.
- CSP should block external resources and navigation.
- IPC sender validation must prevent other windows from invoking onboarding completion if practical within the current data URL sender model.
## Test/check plan
- Add deterministic contract coverage for:
- missing/old state normalizes `onboardingCompleted` to `false`,
- completion helper persists `true`,
- preference updates preserve onboarding completion,
- onboarding completion does not alter installed pets/default pet/agent configuration state.
- Run:
```bash
pnpm test
pnpm check
```
Manual verification should use a fresh or reset dev app state.
## Manual verification guide
1. Reset the dev app state by deleting the OpenPets dev user data directory shown in the app startup log, or remove/edit `openpets-state.json` so onboarding is incomplete.
2. Run:
```bash
pnpm dev:desktop
```
3. Confirm the default pet appears if `openDefaultPetOnLaunch` is enabled.
4. Confirm the onboarding window opens automatically.
5. On Welcome, click Continue.
6. On Pets, click the Pet Manager action and confirm the real Pet Manager opens. Close or leave it open, then continue/skip.
7. On Agents, click the Agent Setup action and confirm the real Configure Agents window opens with Claude controls. Do not apply config unless intentionally testing Claude setup. Continue/skip.
8. On Ready, click Finish.
9. Quit OpenPets and launch again with `pnpm dev:desktop`.
10. Confirm onboarding does not auto-open after completion.
11. Reset onboarding state again, relaunch, close the onboarding window without finishing, and confirm **Continue Setup...** is available from the tray and reopens onboarding.
12. Simulate catalog unavailable behavior if practical, for example by disconnecting network or using an invalid catalog URL if supported by the dev environment; confirm onboarding can still be completed.
13. Confirm that a machine without Claude Code configured, or with Claude setup errors, can still complete onboarding.
14. Confirm Claude settings are unchanged unless existing Agent Setup configure/install buttons were explicitly used.
Expected results:
- Onboarding never blocks app/tray/default pet usage.
- Skipping pet install and agent setup still allows finishing.
- Completion persists across restart.
- No Claude settings are modified unless you explicitly use existing Agent Setup configure/install buttons.
## Oracle plan review
Reviewed by Oracle for architecture fit, scope, security/privacy, state migration, testing, and cross-platform risks.
Blockers: none.
Should-fix feedback:
- Ensure onboarding IPC sender validation uses main-process `taskWindows` / `webContents.id` mapping, not renderer-declared DOM values.
- Keep onboarding preload/API narrow; if shared preload exposes broad APIs, existing IPC sender checks must deny install/remove/preferences from onboarding.
- Add acceptance criteria that Finish closes onboarding and refreshes tray so **Continue Setup...** disappears immediately.
- Avoid importing Electron-bound `app-state.ts` directly from Node tests; extract pure normalization/completion helpers or inject temp state paths.
- Keep onboarding snapshot minimal: default pet/onboarding state only; do not duplicate Claude detection/status or expose Claude settings paths in onboarding.
- Manual verification should include Claude unavailable/catalog unavailable cases and confirm no Claude settings changed unless Agent Setup buttons are explicitly used.
Nice-to-have feedback:
- Add a small “Start using OpenPets”/Finish action on Ready that closes onboarding.
- Place tray **Continue Setup...** above manager/settings while incomplete for discoverability.
- Consider a developer-only env var to force onboarding incomplete for manual testing.
Verdict: implementation-ready after sender-validation/preload and state-test clarifications. Scope is appropriate as one phase because it connects existing surfaces instead of rebuilding pet/agent setup logic.
## Oracle feedback disposition
Fixed:
- Added explicit main-process `webContents.id` sender-validation requirement.
- Added shared-preload/broad-API guard requirement.
- Added finish-close/tray-refresh acceptance criterion.
- Clarified state tests should use extracted pure helpers or injected temp state paths, not brittle Electron-bound imports.
- Reduced onboarding snapshot to default pet/onboarding state only and forbade exposing Claude config paths/previews in onboarding.
- Added manual checks for catalog unavailable, Claude unavailable/error states, and no Claude settings mutation unless existing Agent Setup controls are explicitly used.
Accepted nice-to-have:
- Ready/Finish action and discoverable tray placement are included in implementation guidance.
Deferred:
- Developer-only env var to force onboarding incomplete. Manual reset via dev user-data/state file is enough for Phase 09 unless implementation testing shows reset friction is high.
## Oracle implementation review
Initial implementation review:
Blockers: none.
Should-fix feedback:
- Remove onboarding access to the broad `openpets:get-state` handler; onboarding should use only its minimal snapshot.
- Return a minimal completion result instead of the full app state from onboarding completion.
- Wrap automatic onboarding startup so a window creation failure does not exit the tray app.
- Improve onboarding state tests so completion remains true after preference-like updates, and prefer temp/injected persistence coverage if practical.
- Show the current default pet on the Pets step, not only Welcome.
Nice-to-have feedback:
- Disable Finish while completion is in flight.
- Step indicators/back navigation can be considered later if cheap.
- Manual Electron verification is still required.
Final Oracle re-check:
- Blockers: none.
- Remaining should-fix: none from the previous review.
- Verified previous should-fix items are resolved.
- Verdict: proceed to manual Electron verification before user acceptance.
## Oracle implementation feedback disposition
Fixed:
- Added root `pnpm dev:desktop` script so the manual verification command works from the workspace root.
- Removed onboarding from `openpets:get-state` allowlist.
- Onboarding completion now returns `{ onboardingCompleted: true }` instead of full app state.
- Wrapped startup onboarding open in a try/catch so tray/default-pet startup continues on failure.
- Added state-check coverage that onboarding completion remains true after a preference-like update while other preferences are preserved.
- Added current default pet display to the Pets step.
- Disabled the Finish button while completion is in flight.
Deferred:
- Temp-path full app-state persistence test. The current app-state module is Electron-bound through `app.getPath`; Phase 09 covers the extracted pure onboarding normalization/completion contract and leaves broader state-store injection for a later hardening/refactor if needed.
- Clickable/back-capable step indicators. Forward-only onboarding keeps Phase 09 simpler and matches the approved scope.

View file

@ -0,0 +1,346 @@
# Phase 10A: Desktop Package Hardening
## Goal
Add a reliable current-platform desktop packaging checkpoint for the OpenPets tray app and document the MVP validation matrix, while explicitly deferring the unresolved packaged Claude command distribution path to Phase 10B.
Phase 10A should prove that the packaged desktop app itself can launch and run the core local experience: tray, default pet, onboarding, Pet Manager, settings, local state, assets, and local IPC startup/cleanup behavior.
## Non-goals
- No real packaged Claude end-to-end demo unless the user already has published `@open-pets/*` packages available.
- No npm package publishing.
- No bundled packaged MCP/Claude resource command mode.
- No signed/notarized public release.
- No app store work.
- No auto-update system.
- No start-at-login feature.
- No new pet/catalog/agent features.
- No broad UI redesign.
Phase 10B should choose the Claude distribution path:
- publish `@open-pets/mcp` and `@open-pets/claude`, or
- bundle unpacked MCP/Claude command resources inside the packaged app and point Agent Setup at those paths.
## User-visible/manual outcome
From a clean checkout, the user can run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Then launch the generated current-platform packaged app and verify:
- OpenPets starts as a tray/menu bar app.
- The default pet appears and uses packaged assets.
- First-run onboarding can complete.
- Pet Manager opens and can show installed pets/catalog state.
- Settings opens and can update preferences.
- The app writes local IPC discovery state in the user-specific runtime location.
- Quit exits cleanly.
The phase also creates `docs/mvp-validation.md` with a platform matrix, unsigned-app notes, package commands, cleanup notes, and the remaining Phase 10B packaged-Claude gap.
## Acceptance criteria
- A desktop packaging tool/config exists for current-platform local validation.
- Packaging is Node/pnpm based and does not require Bun.
- Root scripts exist:
- `pnpm package:desktop:dir`
- optionally `pnpm package:desktop` if installer packaging is cheap after `--dir` passes.
- Desktop package scripts exist for package/build/check flow.
- Root package script performs a topological build of desktop dependencies before packaging, not only `apps/desktop` TypeScript.
- Packaging includes required runtime files:
- compiled desktop `dist/**`,
- `preload.cjs`,
- `assets/tray-icon.png`,
- bundled/default pet runtime assets or generated-renderer code,
- workspace runtime dependencies required by the desktop app, including built `@open-pets/claude` and `@open-pets/client` files because Agent Setup imports Claude helpers at startup,
- package metadata needed by Electron.
- Packaging must not leave pnpm symlinks that escape the packaged app, especially symlinks back to `packages/*` in the checkout.
- Packaging excludes obvious repo bulk/secrets/user data:
- `v1/`,
- `web/`,
- `docs/phases/`,
- `.env*`,
- Claude settings/backups,
- app user data,
- build caches.
- Package content checks verify expected config/files and guard against obvious inclusion mistakes where practical.
- A post-package content check verifies the generated package directory contains main/preload/assets/runtime workspace deps and does not contain forbidden repo bulk/secrets where practical.
- Packaged resource path assumptions are audited and fixed if needed:
- tray icon loading,
- preload loading,
- built-in pet loading,
- internal data URLs/task windows,
- local IPC discovery file paths.
- Renderer security remains unchanged:
- `nodeIntegration: false`,
- `contextIsolation: true`,
- `sandbox: true`,
- CSP/navigation guards retained.
- Local dev command mode for Agent Setup remains unavailable or clearly dev-only in packaged builds.
- Published/npx Claude commands remain documented as requiring real published packages; packaged Claude end-to-end is explicitly deferred to Phase 10B.
- In Phase 10A packaged app verification, do not apply Claude MCP/hooks unless `@open-pets/*` packages are actually published and intentionally being tested.
- `docs/mvp-validation.md` documents:
- platform validation matrix for macOS/Windows/Linux,
- current-platform package command,
- unsigned local-app warnings,
- how to launch the packaged output,
- cleanup/uninstall notes,
- which MVP demo rows are verified in Phase 10A vs deferred to Phase 10B.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` succeeds on the current platform, or the exact blocker and next action are documented.
## Proposed files/directories
- `package.json`
- Add root packaging scripts.
- `apps/desktop/package.json`
- Add package scripts and packaging dev dependency, likely `electron-builder`.
- `apps/desktop/electron-builder.yml`
- Define local validation packaging config: stable app id, product name, output directory, explicit files/resources, disabled publish, and signing/notarization deferral.
- `.gitignore`
- Ensure package output directory is ignored.
- `apps/desktop/src/assets.ts`
- Harden packaged resource path lookup if needed.
- `apps/desktop/src/built-in-pet.ts`, `apps/desktop/src/pet-window.ts`, or related files
- Harden packaged built-in pet asset lookup only if package testing exposes a failure.
- `apps/desktop/src/check-packaging-contract.ts`
- Deterministic packaging config/content guard where practical.
- `docs/mvp-validation.md`
- MVP package/manual validation checklist and matrix.
- `docs/phases/phase-10a-desktop-package-hardening.md`
## Technical approach
### Packaging tool
Use `electron-builder` for Phase 10A unless implementation testing reveals a blocker.
Initial target should be unpacked/current-platform packaging first:
```bash
pnpm package:desktop:dir
```
Installer targets can be added only after `--dir` works and if the config is straightforward. The phase can still pass with `--dir` as the stable packaging checkpoint if installers need more platform-specific work.
### Build order
Packaging must not assume only `apps/desktop` needs building.
Root scripts should build relevant workspace dependencies before packaging. For example:
```json
{
"package:desktop:dir": "pnpm build && pnpm --filter @open-pets/desktop package:dir"
}
```
If full workspace build is too slow but reliable dependency filtering is easy, it can be narrowed later.
### Package contents
Start with a conservative `electron-builder` file list that packages only the desktop app runtime. Do not package the entire repository.
Candidate includes:
- `dist/**`
- `preload.cjs`
- `assets/**`
- built workspace runtime dependencies needed by desktop imports, especially `@open-pets/claude` and `@open-pets/client`
- `package.json`
If built-in pet assets live outside those paths or are resolved through `app.getAppPath()`, include them explicitly and adjust path helpers. If the bundled default pet is generated/rendered from compiled code rather than external files, document that in the packaging contract.
The generated package must not depend on symlinks that escape to the source checkout. If pnpm workspace layout causes symlink leakage, use an explicit staging/deploy step rather than shipping checkout-relative links.
### pnpm workspace handling
Do not add root `.npmrc` hoisting/linker changes preemptively.
If `electron-builder` fails because of pnpm workspace dependency layout:
1. Prefer explicit file/resource inclusion and package-local fixes.
2. Consider a staging/deploy step.
3. Only add root pnpm linker changes if necessary, with documentation and another `pnpm check` run.
### Claude packaging limitation
Phase 10A must not pretend packaged Claude works while packages are private/unpublished.
Agent Setup published mode may still show `npx -y @open-pets/...`, but `docs/mvp-validation.md` should state that a packaged end-to-end Claude demo requires Phase 10B unless those packages have been published.
Local dev command mode should stay disabled in packaged builds, because absolute checkout paths are not suitable for a distributed app.
### Validation docs
`docs/mvp-validation.md` should include a table like:
```text
Area Phase 10A status
Tray/default pet packaged Verify now
Onboarding packaged Verify now
Pet Manager packaged Verify now
Claude MCP via npx Deferred unless packages published
Claude hooks via npx Deferred unless packages published
Packaged bundled commands Phase 10B
```
## Risks and tradeoffs
- **This is not full MVP distribution yet.** It intentionally defers packaged Claude commands to avoid hiding a publish/bundle decision.
- **electron-builder + pnpm may need staging.** Avoid global linker changes unless required.
- **Unsigned apps may show OS warnings.** Accept this for local validation and document it.
- **Cross-platform validation cannot be completed from one OS.** Define the matrix now; mark non-current platforms unverified until tested.
- **Packaging can reveal path assumptions.** Fix concrete packaged-mode failures only.
## Security/privacy notes
- Do not weaken renderer sandbox/context isolation/CSP.
- Do not introduce TCP/HTTP control surfaces.
- Do not include secrets, local app data, Claude settings, or backups in package output.
- Keep local IPC local-only and token-gated.
- Claude config mutation remains confirmation-based through existing Agent Setup behavior.
- Unsigned package output is for local MVP validation, not public trust distribution.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Add/extend deterministic checks for:
- packaging config exists,
- package scripts exist,
- required runtime files are included by config or copied resources,
- obviously forbidden repo/user-data patterns are not included by config,
- generated package output contains main/preload/assets and workspace runtime deps after `package:desktop:dir`,
- generated package output does not contain symlinks escaping the package directory,
- root packaging command is available.
Manual verification covers actual packaged app launch and resource loading.
## Manual verification guide
1. Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
2. Launch the generated app from the package output.
3. Confirm tray icon appears.
4. Confirm default pet appears and can be shown/hidden/paused/resumed.
5. Complete or reset onboarding and confirm it behaves correctly in packaged mode.
6. Open Pet Manager and confirm installed pets/catalog state renders.
7. Open Settings and toggle a preference; restart packaged app and confirm persistence.
8. Confirm local IPC discovery is created in a per-user location while the app runs and cleaned/stale-safe on quit as documented.
- While the packaged app is running, use an existing checkout client smoke/status command if available to verify discovery, token, and status endpoint against the packaged app.
9. Open Agent Setup and confirm local dev mode is unavailable/clearly dev-only in packaged builds.
10. Confirm docs clearly mark packaged Claude MCP/hooks as deferred to Phase 10B unless npm packages are published. Do not apply Claude MCP/hooks from the packaged app during Phase 10A unless intentionally testing published packages.
11. If catalog/fixture is available, install a pet and set it as default in packaged mode; if unavailable, mark catalog install as unverified and record the catalog error.
12. Quit from tray and confirm clean exit.
## Oracle plan review
Reviewed by Oracle after user chose to split broad Phase 10 into Phase 10A/10B.
Blocker:
- Even with Claude end-to-end deferred, desktop imports `@open-pets/claude` through Agent Setup, and that imports `@open-pets/client`. Phase 10A must explicitly include those workspace runtime deps' built files or use staging/deploy; packaging only desktop `dist`, preload, assets, and package metadata is likely insufficient.
Should-fix feedback:
- Require no symlinks escaping the packaged app, especially pnpm workspace symlinks to `packages/*`.
- Make electron-builder config requirements explicit: stable `appId`, `productName`, output dir, `publish` disabled, signing/notarization deferred, output ignored.
- Add post-package content checks, not only config checks.
- Add manual local IPC smoke step from checkout against packaged app.
- Strengthen Agent Setup warning: do not apply Claude MCP/hooks in Phase 10A packaged app unless packages are actually published.
- Pet Manager verification should include install/set-default if catalog/fixture is available, or explicitly mark catalog install unverified if unavailable.
Nice-to-have feedback:
- Keep Phase 10A to `electron-builder --dir` first.
- Prefer staging/deploy over root linker changes if pnpm fights builder.
- Clarify bundled default pet assets if generated rather than file assets.
Verdict: original packaged-Claude blocker is resolved by the split. Phase 10A is nearly implementation-ready after making workspace runtime dependency inclusion a hard acceptance criterion.
## Oracle feedback disposition
Fixed:
- Added hard acceptance criterion for packaged workspace runtime deps, especially `@open-pets/claude` and `@open-pets/client`.
- Added no-escaping-symlink acceptance criterion.
- Made electron-builder config expectations explicit.
- Added post-package content check requirement.
- Added local IPC smoke verification step.
- Strengthened packaged Agent Setup warning not to apply Claude MCP/hooks unless packages are actually published.
- Added Pet Manager install/set-default manual check when catalog/fixture is available, with explicit unverified note if unavailable.
- Clarified default pet assets may be generated/runtime code rather than external files.
Accepted:
- Start with `--dir` packaging first; installers are optional after `--dir` passes.
- Prefer staging/deploy over root pnpm linker changes if needed.
Deferred:
- Packaged Claude end-to-end command distribution remains Phase 10B.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix feedback:
- Manually verify the packaged GUI before closing Phase 10A: tray, default pet, onboarding, Pet Manager, Settings, IPC smoke, quit cleanup.
- Tighten or explicitly disposition package over-inclusion. Current unpacked app may include workspace package `src/`, `tsconfig.json`, built check scripts, and source maps. This is acceptable for local inspectable Phase 10A if documented, but does not fully match runtime-only packaging intent.
- Extend package output contract checks to third-party runtime deps such as `yauzl` and transitive deps.
Nice-to-have feedback:
- Document `npmRebuild: false` assumption as acceptable because current deps are JS-only/no native modules.
- Keep `asar: false` for Phase 10A only and revisit for release hardening.
- Exact script assertions in packaging checks are acceptable at current scale, though less brittle assertions may be useful later.
Verdict: acceptable for Phase 10A after manual packaged-app verification.
## Oracle implementation feedback disposition
Fixed:
- Added package output assertions for `yauzl` and its runtime helpers/transitive dependencies.
- Documented Phase 10A package over-inclusion as an intentional local/inspectable packaging tradeoff in `docs/mvp-validation.md`.
- Documented `npmRebuild: false` as safe only while runtime deps are JavaScript-only.
Deferred:
- Runtime-only ASAR packaging. Phase 10A intentionally uses unpacked output for inspectable package checks; release hardening should revisit ASAR/runtime-only output before public distribution.
Needs manual verification:
- Packaged GUI launch and full Phase 10A manual checklist must be completed by the user before committing/closing this phase.
Final Oracle re-check:
- Blockers: none.
- Remaining implementation/spec should-fix: none.
- Phase gate still remaining: user manual packaged GUI verification before closing Phase 10A.
- Verdict: proceed to user manual verification; do not close/commit phase until that checklist passes.

View file

@ -0,0 +1,333 @@
# Phase 10B: Packaged Claude Command Distribution
## Goal
Make the packaged OpenPets desktop app capable of configuring Claude Code without relying on unpublished `npx @open-pets/*` packages.
Phase 10B should add a packaged/bundled command mode that points Claude MCP and Claude hooks at JavaScript entry points shipped inside the packaged app resources.
## Non-goals
- No npm publishing.
- No public signed/notarized release.
- No auto-update.
- No changing the public MCP tool set.
- No new agent integrations beyond Claude.
- No model-generated speech.
- No removing the existing published `npx` command mode for future published packages.
- No weakening Claude configuration confirmation/backups.
## User-visible/manual outcome
In the packaged app, Agent Setup no longer asks users to configure private/unpublished `npx` packages by default.
Instead, packaged Agent Setup previews and installs commands like:
```text
node <packaged-app-resource>/app/node_modules/@open-pets/mcp/dist/index.js
node <packaged-app-resource>/app/node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed
```
The exact path is platform-specific, but it must be inside the packaged app output/resources and must be a regular file.
In dev/unpackaged mode, the existing behavior remains:
- published mode: `npx -y @open-pets/...`
- local dev mode: built checkout `dist` paths
## Acceptance criteria
- `@open-pets/desktop` packages the runtime packages required for bundled Claude commands:
- `@open-pets/mcp`,
- `@open-pets/claude`,
- `@open-pets/client`,
- required third-party runtime dependencies.
- Agent Setup chooses command mode by environment:
- packaged app: bundled packaged mode,
- dev app: published/local dev toggle remains available.
- The Agent Setup renderer/preload accepts and renders bundled command mode snapshots without treating them as invalid.
- Packaged Agent Setup checks Node availability for Claude-launched commands, or clearly disables configure/install with a warning if Node is unavailable/too old.
- Packaged mode command previews use `node <packaged resource path>` and never `npx -y @open-pets/...` unless the user is explicitly in published mode in dev.
- Packaged MCP command points at a regular file shipped inside package resources.
- Packaged hook command points at a regular file shipped inside package resources.
- Packaged command paths are not source-checkout paths and do not use `app.getAppPath()` assumptions that fail after packaging.
- Packaged commands are validated before install/doctor/configure actions:
- path exists,
- path is a regular file,
- real path is inside packaged app resources/app directory,
- symlink escapes are rejected,
- `.asar` paths are rejected unless the file is explicitly unpacked/readable as a regular file,
- command contains no newline/null/unsupported shell characters.
- Missing bundled resources produce an actionable Agent Setup status/error, not an unhandled IPC failure.
- Claude MCP configure/replace/remove still uses `claude mcp ...` and still requires explicit user button actions.
- Claude hook install/update/uninstall still writes only OpenPets-managed hooks and still creates backups.
- Doctor/preview text clearly labels packaged bundled mode and explains commands point into the installed OpenPets app.
- If packaged bundled command resources are missing, Agent Setup shows an actionable error and disables configure/install actions rather than falling back silently to unpublished `npx` packages.
- Existing contract checks cover packaged bundled command files in package output.
- Existing Claude code/hooks contract checks cover bundled command preview/matching behavior.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes.
## Proposed files/directories
- `packages/claude/src/claude-code.ts`
- Add command mode support for packaged bundled MCP command paths.
- `packages/claude/src/hook-settings.ts`
- Add command mode support for packaged bundled hook CLI command paths.
- `packages/claude/src/check-claude-code.ts`
- Add bundled command preview/status checks.
- `packages/claude/src/check-claude-hooks.ts`
- Add bundled hook settings checks.
- `apps/desktop/src/agent-setup.ts`
- Select bundled mode in `app.isPackaged`.
- Validate packaged resource paths and surface errors.
- Check/warn for Node availability for packaged bundled commands.
- Keep dev local toggle unavailable in packaged app.
- `apps/desktop/preload.cjs`
- Accept/render `commandMode: "bundled"` snapshots and label bundled mode clearly.
- `apps/desktop/package.json`
- Add `@open-pets/mcp` as a desktop runtime dependency so packaged output includes it.
- `apps/desktop/src/check-packaging-contract.ts`
- Require packaged MCP/Claude/client entry files and MCP runtime deps.
- `docs/mvp-validation.md`
- Update Phase 10B status and manual Claude packaged validation steps.
- `docs/phases/phase-10b-packaged-claude-command-distribution.md`
## Technical approach
### Command mode model
Extend `OpenPetsCommandMode` from:
```ts
"published" | "local"
```
to:
```ts
"published" | "local" | "bundled"
```
Semantics:
- `published`: npm/npx package names.
- `local`: checkout-relative built `dist` files for development.
- `bundled`: package-relative files shipped in the packaged app's `node_modules/@open-pets/*/dist` directories.
For current unpacked Phase 10A packaging (`asar: false`), `bundled` paths can be resolved from `@open-pets/claude`'s own `import.meta.url` in packaged node_modules:
```text
.../Resources/app/node_modules/@open-pets/claude/dist
.../Resources/app/node_modules/@open-pets/mcp/dist/index.js
```
This mirrors the current `local` sibling-package resolution, but should be named `bundled` so the UI does not imply a fragile source checkout path.
### Desktop mode selection
In desktop Agent Setup:
- `app.isPackaged` should force `bundled`.
- Dev mode keeps current published/local toggle.
- UI should show the dev checkbox disabled in packaged app, with text indicating packaged bundled commands are used.
### Hook settings
Bundled hook settings should still use the OpenPets marker:
```text
node "<path>/@open-pets/claude/dist/cli.js" hook --openpets-managed
```
Uninstall must continue to remove any OpenPets-managed hooks by marker, including old published/local commands.
Install/update should replace old OpenPets-managed published/local commands with bundled commands when running from packaged app.
### MCP settings
Bundled MCP settings should configure Claude with:
```json
{
"type": "stdio",
"command": "node",
"args": ["<path>/@open-pets/mcp/dist/index.js"]
}
```
If a pet is selected:
```json
"args": ["<path>/@open-pets/mcp/dist/index.js", "--pet", "pet-id"]
```
### Packaging contract
Package output checks should assert:
- `node_modules/@open-pets/mcp/dist/index.js` exists.
- `node_modules/@open-pets/claude/dist/cli.js` exists.
- `node_modules/@open-pets/client/dist/index.js` exists.
- required MCP third-party dependencies exist (`@modelcontextprotocol/sdk`, `zod`, etc.) if included by builder.
- no symlink escapes package output.
### Security/safety
Path validation should reject:
- missing files,
- directories/symlinks when a regular file is expected,
- paths whose `realpath` is outside the packaged app resource/app root in bundled mode,
- `.asar` paths unless the target is explicitly unpacked/readable as a regular file,
- command strings containing newline/null.
Do not silently fall back to `npx` in packaged app if bundled resources are missing; that would reintroduce the Phase 10A blocker.
## Risks and tradeoffs
- **Installed app path moves.** Claude settings will contain absolute paths into the installed app; if the user moves/deletes OpenPets, Agent Setup doctor should report needs update/error and the user should reinstall/update config.
- **ASAR disabled in Phase 10A.** Bundled commands rely on regular files. If ASAR is enabled later, MCP/Claude command files must move to `asarUnpack` or `extraResources`.
- **Node availability.** Commands use `node`, so users still need Node available for Claude to launch MCP/hooks. This matches the project's Node/npm/npx direction and avoids bundling a separate runtime in this phase.
- **Cross-platform paths.** Windows paths with spaces must be handled through Claude MCP arg arrays and shell-quoted hook command strings.
## Security/privacy notes
- No silent Claude configuration changes.
- Backups remain required before Claude settings hook writes.
- Bundled commands are local files shipped with OpenPets, not remote downloads.
- No TCP/HTTP control plane is introduced.
- Speech/privacy rules from earlier phases remain unchanged.
- Uninstall removes only OpenPets-managed hooks by marker.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Automated coverage:
- bundled MCP preview command shape,
- bundled hook command shape,
- parse/classify expected bundled Claude MCP entries,
- bundled paths with spaces/backslashes in MCP arg arrays and hook shell quoting,
- bundled command path validation for existing and missing files,
- renderer/preload snapshot validation accepts `commandMode: "bundled"`,
- package output includes MCP/Claude/client runtime entries and MCP runtime deps.
## Manual verification guide
1. Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
2. Launch the packaged app.
3. Open Agent Setup.
4. Confirm command preview uses `node <packaged .../node_modules/@open-pets/mcp/dist/index.js>`, not `npx -y @open-pets/mcp`.
5. Confirm hook preview uses `node <packaged .../node_modules/@open-pets/claude/dist/cli.js> hook --openpets-managed`.
6. Configure Claude MCP only if you are ready to update your user Claude settings; verify backup/config behavior as in earlier phases.
7. Install/update hooks only if you are ready to update your user Claude settings; verify backup/config behavior as in earlier phases.
8. Run Claude Code and confirm MCP/hook behavior if desired.
9. Use Agent Setup to remove MCP/hooks after testing if you do not want packaged paths left in Claude settings.
## Oracle plan review
Reviewed by Oracle.
Blocker:
- The original plan omitted renderer/preload bundled-mode support. Current `apps/desktop/preload.cjs` validates command mode as only `published` or `local`, so a bundled snapshot would be rejected. The spec must include preload/renderer handling.
Should-fix feedback:
- Add explicit Node prerequisite handling because packaged bundled commands require `node` on Claude's PATH.
- Validate bundled paths with `realpath` against the packaged app root, not simple string prefix. Reject missing files, directories, symlinks, `.asar` paths unless unpacked, newline/NUL/quote/shell-dangerous chars.
- Ensure missing bundled resources produce actionable Agent Setup status/error instead of unhandled IPC failure.
- Add cross-platform tests for paths with spaces/backslashes, MCP args arrays, hook shell quoting, and bundled `claude mcp get` parsing.
- Extend package contract checks to require `@open-pets/mcp/dist/index.js` plus runtime deps like `@modelcontextprotocol/sdk` and `zod`.
- Update docs to state Claude settings contain absolute OpenPets app paths; moving/deleting/updating the app may require Agent Setup replace/update/remove.
Nice-to-have feedback:
- Centralize command-mode/path resolution helpers.
- Show shortened paths in UI while writing full absolute paths to Claude settings.
- Add future ASAR note for `asarUnpack` or `extraResources`.
Verdict: architecture is sound, but not implementation-ready until preload bundled-mode support and path/quoting/test clarifications are added.
## Oracle feedback disposition
Fixed:
- Added `apps/desktop/preload.cjs` to proposed files and acceptance criteria for bundled-mode renderer support.
- Added Node prerequisite warning/disable requirement.
- Strengthened realpath/path validation requirements.
- Added missing-resource error-state requirement.
- Added cross-platform path/quoting tests to test plan.
- Added MCP runtime deps to package contract requirements.
- Added absolute packaged path docs requirement.
Accepted:
- Centralize command/path helpers where practical during implementation.
- Show shortened paths in UI while writing full paths if cheap.
Deferred:
- ASAR support remains future release hardening; Phase 10B should note that ASAR requires `asarUnpack`/`extraResources` for externally executed JS files.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix feedback:
- Prevent broken writes when `node` is unavailable. `detectClaudeCodeStatus()` reports “Node required”, but action handlers must also guard configure/replace/install-hooks in bundled mode.
- Keep cleanup available on bundled-resource errors. `remove` and `uninstall-hooks` should not be blocked by missing bundled MCP/hook resources.
- Add stronger Windows/path tests for hook command quoting, MCP args arrays, and text parsing of bundled paths with spaces.
- Reject symlink command files or document reliance on package checks.
- Manual packaged Agent Setup verification remains required.
Nice-to-have feedback:
- Make bundled tests explicitly depend on built `@open-pets/mcp` or avoid relying on stale sibling `dist` state.
- Clarify Node PATH check is best-effort because Claude's runtime environment may differ.
- Keep ASAR/`extraResources` deferred as documented.
Verdict: architecture is sound and package contract is substantially adequate; fix node/action guard and cleanup-path issues before final acceptance.
## Oracle implementation feedback disposition
Fixed:
- Added action-level bundled Node guard before configure/replace/install-hooks.
- Moved cleanup actions so `remove` and `uninstall-hooks` remain available even if bundled command resources are missing.
- Added quoted-path parsing and Windows-style path display checks for Claude MCP command handling.
- Runtime bundled command validation now rejects symlink entry files in addition to using `realpath` containment checks.
Accepted:
- Manual packaged Agent Setup verification remains a phase gate.
Deferred:
- ASAR/`extraResources` release hardening remains deferred.
Final Oracle re-check:
- Blockers: none.
- Remaining should-fix: none from the prior Oracle review.
- Manual packaged Agent Setup UI verification remains as the phase gate before closing.
- Verdict: proceed to user manual packaged verification for bundled MCP preview/configure, hook preview/install, real Claude behavior if desired, and cleanup/remove.

View file

@ -0,0 +1,257 @@
# Phase 10C: ASAR Runtime Packaging
## Goal
Move the desktop package from fully unpacked local-validation output to a more release-like ASAR package while keeping Claude's externally executed MCP and hook commands available as regular unpacked files.
Phase 10C should reduce package over-inclusion from Phase 10A/10B and prove the packaged app still works with bundled Claude commands.
## Non-goals
- No public signed/notarized release.
- No installer targets beyond the current `--dir` package checkpoint unless trivial.
- No npm publishing.
- No auto-update.
- No start-at-login.
- No new agent integrations.
- No changing Claude hook/MCP behavior except paths needed for ASAR packaging.
## User-visible/manual outcome
The current-platform package still builds with:
```bash
pnpm package:desktop:dir
```
The generated app uses ASAR for the Electron app contents, but still includes unpacked files that Claude can execute with external `node`:
```text
app.asar
app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js
app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js
```
Agent Setup packaged previews should point at `app.asar.unpacked/...` paths, not `app.asar/...` paths and not source checkout paths.
## Acceptance criteria
- `electron-builder` ASAR packaging is enabled.
- Externally executed bundled command files are unpacked:
- `@open-pets/mcp/dist/index.js`,
- `@open-pets/claude/dist/cli.js`,
- package metadata (`package.json`) needed for ESM/package resolution,
- all runtime files and transitive dependencies those commands need when launched by external `node`.
- Packaged Agent Setup bundled MCP preview points at a regular file outside `app.asar`, preferably under `app.asar.unpacked`.
- Packaged hook preview points at a regular file outside `app.asar`, preferably under `app.asar.unpacked`.
- Desktop runtime imports from ASAR still work for the Electron app itself.
- Package contract checks assert:
- `app.asar` exists,
- externally executed command entry files exist under unpacked resources,
- unpacked command package metadata exists,
- external `node` can execute packaged MCP/Claude command entry points in safe smoke modes,
- generated bundled command preview paths do not contain `.asar` except `.asar.unpacked`,
- no symlinks escape package output,
- forbidden repo/user-data patterns remain absent.
- Runtime bundled path resolution handles both dev/unpacked and packaged ASAR layouts.
- Claude bundled path validation still rejects missing files, symlinks, and true `app.asar` paths, while allowing `app.asar.unpacked` paths.
- `docs/mvp-validation.md` is updated to describe ASAR + unpacked command resources.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes.
## Proposed files/directories
- `apps/desktop/electron-builder.yml`
- Enable ASAR and configure unpacking for externally executed OpenPets command resources.
- `packages/claude/src/claude-code.ts`
- Resolve bundled MCP path to ASAR-unpacked location when running from ASAR.
- `packages/claude/src/hook-settings.ts`
- Resolve bundled Claude CLI path to ASAR-unpacked location when running from ASAR.
- `packages/claude/src/check-claude-code.ts`
- Cover ASAR-to-unpacked path mapping.
- `packages/claude/src/check-claude-hooks.ts`
- Cover ASAR-to-unpacked hook CLI path mapping.
- `apps/desktop/src/check-packaging-contract.ts`
- Validate ASAR/unpacked package output and bundled command files.
- `docs/mvp-validation.md`
- Update packaging notes.
- `docs/phases/phase-10c-asar-runtime-packaging.md`
## Technical approach
### Builder config
Enable ASAR:
```yaml
asar: true
```
Use `asarUnpack` for externally executed command resources and their module metadata/dependency closure. Initial candidate:
```yaml
asarUnpack:
- node_modules/@open-pets/mcp/**
- node_modules/@open-pets/claude/**
- node_modules/@open-pets/client/**
- node_modules/@modelcontextprotocol/**
- node_modules/zod/**
```
If runtime smoke testing shows additional MCP SDK transitive dependencies are required for external `node`, include them. Prefer a minimal external command runtime subtree, but correctness is more important than micro-optimizing package size in this phase.
### Path mapping
When `@open-pets/claude` runs inside Electron from `app.asar`, its `import.meta.url` may include `app.asar`. Bundled command paths for Claude must map to `app.asar.unpacked`.
Add a pure, tested path mapper with behavior:
```text
.../Resources/app.asar/node_modules/@open-pets/claude/dist/index.js
=>
.../Resources/app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js
.../Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js
```
In dev/non-ASAR mode, continue resolving sibling workspace package paths as today.
Validation must reject true `app.asar` command paths but allow `app.asar.unpacked` paths.
### Validation
Package output contract should locate either:
- ASAR + `app.asar.unpacked` resources from Phase 10C.
Phase 10C should prefer ASAR and fail if `app.asar` is missing after package output.
Because forbidden-file checks cannot simply walk inside `app.asar` as a directory, use an ASAR library/tool or verify through builder file config plus unpacked tree checks.
Post-package smoke checks should execute:
```bash
node <app.asar.unpacked>/node_modules/@open-pets/mcp/dist/index.js --version
node <app.asar.unpacked>/node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed
```
The hook smoke must use isolated/missing discovery input so it does not contact a real desktop app.
## Risks and tradeoffs
- **Dependency completeness:** external `node` running unpacked MCP/hook files may need dependencies that were left inside `app.asar`. Package contract and manual Claude tests must catch this.
- **Path mapping:** ASAR path rewrites are easy to get subtly wrong on Windows. Keep mapping string-based and covered by tests.
- **Package size:** unpacking command packages duplicates some files. Accept for this MVP unless it becomes excessive.
- **Future installers/signing:** signing/notarization remains deferred.
## Security/privacy notes
- Do not weaken renderer sandbox/context isolation/CSP.
- Do not include user data, Claude settings, backups, or secrets in package output.
- Claude config changes remain explicit/backup-protected.
- Externally executed command files must be shipped by OpenPets, not downloaded dynamically.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Automated coverage:
- ASAR path maps to ASAR-unpacked command path.
- True `app.asar` command paths are rejected while `app.asar.unpacked` command paths are allowed.
- Package output contains `app.asar` and unpacked command resources.
- Packaged command resources are regular files, not symlinks.
- External `node` smoke-executes packaged MCP/Claude command files.
## Manual verification guide
Manual verification is provided after implementation.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Make `asarUnpack` dependency closure explicit; external `node` cannot resolve modules left inside `app.asar`.
- Include package metadata in unpacked resources because ESM packages need `package.json`, `type`, and exports metadata.
- Add post-package smoke checks that actually run packaged MCP and Claude CLI entry points with safe inputs.
- Change validation semantics from rejecting any `.asar` to rejecting true `app.asar` while allowing `app.asar.unpacked`.
- Add pure/tested path mapper for `app.asar``app.asar.unpacked`, including Windows/backslash and spaces cases.
- Update package contract for ASAR-era layout: `app.asar` exists and command files are regular non-symlink files under `app.asar.unpacked`.
- If forbidden-file checks cannot walk ASAR contents, inspect ASAR with a tool/library or explicitly verify builder config plus unpacked tree checks.
Nice-to-have feedback:
- Prefer a minimal external command runtime subtree, but do not over-optimize before smoke checks pass.
- Document ASAR as packaging hygiene, not a security boundary.
- Keep installer/signing deferred.
Verdict: implementation-ready after tightening unpacked dependency/metadata and smoke-test requirements.
## Oracle feedback disposition
Fixed:
- Added explicit unpacked dependency and package metadata requirements.
- Added external node smoke-test requirements for MCP and Claude command entry points.
- Clarified validation should reject true `app.asar` but allow `app.asar.unpacked`.
- Added pure ASAR path mapper requirement and ASAR-era package contract requirements.
- Clarified forbidden-file checks must account for ASAR not being a normal directory.
Accepted:
- Keep installer/signing deferred.
- Document ASAR as packaging hygiene, not a security boundary in implementation docs.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blocker:
- The ASAR-unpacked command target was mapped from `app.asar` to `app.asar.unpacked`, but the bundled validation root was still derived from `import.meta.url` under `app.asar`. Valid unpacked command paths could be rejected as outside root.
Should-fix feedback:
- Add a test for validation/root containment behavior, not only the string mapper.
- Manual packaged Agent Setup UI verification remains required.
- Consider cleaning `dist-electron` before packaging to avoid stale output masking regressions.
Nice-to-have feedback:
- Narrow `asarUnpack` later; `node_modules/**` is acceptable for MVP because command smokes prove dependency closure.
- Make `mapAsarPathToUnpacked` match `app.asar` as a path segment only.
Verdict: close, but fix ASAR containment-root issue before acceptance.
## Oracle implementation feedback disposition
Fixed:
- Bundled validation roots now pass through the same `app.asar``app.asar.unpacked` mapper as command target paths.
- `mapAsarPathToUnpacked` now matches `app.asar` as a path segment and leaves `app.asar.unpacked` unchanged.
- Added mapper coverage for path-segment behavior and already-covered command smoke tests validate packaged command files under `app.asar.unpacked`.
Accepted:
- `node_modules/**` remains unpacked for this MVP phase to keep external command dependency closure reliable.
Needs manual verification:
- Packaged Agent Setup UI must be manually verified after implementation.
Final Oracle re-check:
- Blockers: none.
- Remaining should-fix: none from the prior review.
- Manual packaged Agent Setup/Claude verification remains the phase gate before closing.
- Verdict: ASAR-to-unpacked path mapping and containment validation look correct; package command smokes passed; proceed to manual packaged verification.

View file

@ -0,0 +1,164 @@
# Phase 10D: Package Polish and Clean Output
## Goal
Polish the desktop package metadata and make package output deterministic by cleaning stale output before each package build.
This phase targets the warnings/noise surfaced during Phase 10A-10C packaging, without taking on signing, notarization, installers, or new product features.
## Non-goals
- No code signing or notarization.
- No installer targets beyond the current `--dir` package checkpoint.
- No app icon format generation unless it is trivial and safe.
- No npm publishing.
- No auto-update.
- No changes to Claude MCP/hooks behavior.
## User-visible/manual outcome
Packaging should be cleaner and less fragile:
- desktop package metadata includes description/author fields used by packagers,
- packaging scripts remove stale `dist-electron` output before rebuilding,
- package contract checks enforce clean-output behavior and metadata presence,
- documentation notes remaining unsigned/default-icon limitations.
## Acceptance criteria
- `apps/desktop/package.json` includes package metadata expected by `electron-builder`, at minimum:
- `description`,
- `author`.
- Package scripts clean `apps/desktop/dist-electron` before running `electron-builder --dir`.
- Both `package` and `package:dir` clean `apps/desktop/dist-electron` before running `electron-builder` because both write that output directory.
- Cleanup uses a repository-owned helper that resolves and verifies the exact target before deleting; it does not accept path/env arguments.
- Root `pnpm package:desktop:dir` still performs a full workspace build before packaging.
- Package contract checks assert metadata presence and clean-output script behavior.
- Package contract checks verify clean-output behavior with a stale sentinel or equivalent helper contract, not only script text.
- `docs/mvp-validation.md` documents any remaining package warnings, including unsigned app and default app icon if still present.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes.
## Proposed files/directories
- `apps/desktop/package.json`
- Add metadata and clean-output package script.
- `apps/desktop/src/check-packaging-contract.ts`
- Assert metadata and package script behavior.
- `apps/desktop/scripts/clean-package-output.cjs`
- Cross-platform cleanup helper for `dist-electron`.
- `docs/mvp-validation.md`
- Update package warning notes.
- `docs/phases/phase-10d-package-polish-clean-output.md`
## Technical approach
Use only Node/pnpm commands. Add a tiny CommonJS cleanup helper instead of shell-specific `rm -rf` or brittle inline `node -e` quoting:
```text
apps/desktop/scripts/clean-package-output.cjs
```
The helper should:
1. Resolve `apps/desktop/dist-electron` relative to its own location.
2. Verify the basename is `dist-electron` and parent is the desktop package root.
3. Delete with `fs.rmSync(target, { recursive: true, force: true })`.
4. Accept no path/env overrides.
Package scripts should run the helper before `electron-builder`:
```json
"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder",
"package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output"
```
Do not add icon generation in this phase unless the existing PNG can be safely referenced without platform-specific conversion issues.
## Risks and tradeoffs
- Cleaning output removes previous packaged builds, which is intended for deterministic packaging.
- App icon polish may need platform-specific `.icns` / `.ico` generation; defer if not trivial.
- Metadata should not imply public distribution readiness while signing/notarization are still deferred.
## Security/privacy notes
- Cleaning only targets `apps/desktop/dist-electron`.
- Do not include user data, Claude settings, backups, or secrets.
- No changes to Claude config mutation behavior.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
## Manual verification guide
Manual verification is provided after implementation.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Use a real cross-platform cleanup helper instead of inline `node -e`; inline example was invalid/brittle.
- Cleanup helper must resolve and verify exact target `apps/desktop/dist-electron` before deleting, with no path/env override.
- Acceptance should verify clean behavior, not only script text.
- Clarify both `package` and `package:dir` clean output.
- Add cleanup helper path to proposed files.
Nice-to-have feedback:
- Keep icon generation deferred and document default Electron icon warning.
- Metadata should not imply public release readiness while signing/notarization are deferred.
- Avoid overly brittle full-string script assertions where possible.
Verdict: implementation-ready after cleanup helper/verification clarifications.
## Oracle feedback disposition
Fixed:
- Replaced inline cleanup approach with a dedicated helper.
- Added exact-target verification/no-override requirements.
- Added clean-behavior verification requirement.
- Clarified both package scripts clean output.
- Added helper file to proposed files.
Accepted:
- Icon generation remains deferred and will be documented as a known package warning.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Document that desktop `pnpm test` may delete `apps/desktop/dist-electron` because it exercises the cleanup helper.
- Consider making contract script assertions less exact over time; current checks are acceptable.
Verdict: implementation is sound. Cleanup is scoped/cross-platform, metadata is appropriate, docs accurately defer default icon/signing, and validation passed.
## Oracle implementation feedback disposition
Fixed:
- Added docs note that desktop package tests may remove `apps/desktop/dist-electron` while exercising the cleanup helper.
Accepted:
- Current script assertions remain as-is for now.

View file

@ -0,0 +1,153 @@
# Phase 10E: App Icon Package Identity
## Goal
Replace the default Electron packaged-app icon with OpenPets-owned package icon assets while keeping tray icon behavior unchanged.
This phase removes the remaining default-icon packaging warning for the current macOS package and documents Windows/Linux icon follow-up if needed.
## Non-goals
- No signing/notarization.
- No installer targets beyond `--dir`.
- No visual redesign of the app UI.
- No new marketing/brand system.
- No replacing the small tray icon unless packaging requires shared source art.
## User-visible/manual outcome
The packaged app no longer uses Electron's default app icon. On macOS package output, `OpenPets.app` has an OpenPets MVP app icon. If generated from the current 64×64 source, the icon is temporary and may look soft at large sizes.
## Acceptance criteria
- A package app icon asset exists under desktop assets/build resources.
- `electron-builder` config points at the app icon.
- macOS `pnpm package:desktop:dir` no longer emits the default Electron icon warning, or the warning remains explicitly documented if generated icon quality is rejected.
- The tray icon remains loaded from `assets/tray-icon.png` and still renders in the tray.
- Package contract checks assert app icon assets/config exist.
- Package contract checks assert tray icon code/config still points at `assets/tray-icon.png`.
- Icon generation must not leave temporary `icon.iconset/` directories in the repository.
- `docs/mvp-validation.md` updates the default-icon note.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes.
## Proposed files/directories
- `apps/desktop/assets/app-icon.icns`
- macOS app icon generated from OpenPets-owned source art.
- `apps/desktop/electron-builder.yml`
- Add macOS icon config.
- `apps/desktop/src/check-packaging-contract.ts`
- Assert icon config and asset exist.
- `docs/mvp-validation.md`
- Update icon warning notes.
- `docs/phases/phase-10e-app-icon-package-identity.md`
## Technical approach
Use the existing `assets/tray-icon.png` as the source for a simple MVP app icon if it produces acceptable results. The current PNG is 64×64, so generated `.icns` will be basic and can be visually improved later with higher-resolution brand artwork.
Generate `.icns` on macOS using built-in tools if available, using a temporary directory outside the repo or cleaning it before completion:
```bash
mkdir -p icon.iconset
sips -z ...
iconutil -c icns icon.iconset
```
If high-quality `.icns` generation is not reliable from the 64×64 source, do not fake a polished icon. Either commit an MVP/temporary `.icns` and document soft quality, or document the asset limitation and defer high-resolution icon work.
Configure electron-builder:
```yaml
mac:
icon: assets/app-icon.icns
```
Keep Windows/Linux icons deferred unless existing tooling can produce `.ico`/PNG assets safely without extra dependencies.
## Risks and tradeoffs
- Source tray icon is only 64×64, so the app icon may look soft at large sizes.
- `.icns` generation is macOS-specific; acceptable because current packaging validation is on macOS.
- Windows/Linux icon polish may need separate assets and should be deferred unless trivial.
## Security/privacy notes
- Icon generation must use repository assets only.
- No user data or config is touched.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Check package output logs for default Electron icon warning absence.
If practical, inspect the generated `.icns` with `iconutil -c iconset` or `file`.
## Manual verification guide
Manual verification is provided after implementation and should include checking the packaged `.app` Finder/Dock icon and confirming the tray icon still appears.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Do not describe an upscaled 64×64 `.icns` as polished; acceptance should allow MVP/temporary quality or deferral if bad.
- Add contract checks for `assets/app-icon.icns`, `mac.icon`, and tray icon still using `assets/tray-icon.png`.
- Add manual verification for packaged Finder/Dock icon and tray icon.
- Ensure temporary `icon.iconset/` is not left in repo.
Nice-to-have feedback:
- Build-resource directory may be better later; `assets/app-icon.icns` is acceptable now.
- Document Windows/Linux icon assets as future work.
- Inspect `.icns` with `iconutil` or `file` if feasible.
Verdict: implementation-ready; frame as MVP identity cleanup, not final branding.
## Oracle feedback disposition
Fixed:
- Spec now frames generated 64×64-derived icon as MVP/temporary and allows deferral if quality is unacceptable.
- Added contract check requirements for app icon, mac icon config, and unchanged tray icon path.
- Added manual verification requirements for packaged app icon and tray icon.
- Added temporary iconset cleanup requirement.
- Added optional `.icns` inspection note.
Accepted:
- Windows/Linux icon assets are future work, not Phase 10E failure.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Contract checks could eventually inspect packaged `.app` icon metadata; current `electron-builder` success and warning disappearance are enough for MVP.
- Future branding should replace the upscaled 64×64-derived `.icns` with high-resolution source art.
Verdict: implementation is acceptable. Asset/config/checks/docs align with MVP package identity scope, tray behavior is guarded, and remaining signing/platform icon work is correctly deferred.
## Oracle implementation feedback disposition
Accepted:
- Packaged `.app` metadata inspection remains future hardening.
- High-resolution branding remains future polish.

View file

@ -0,0 +1,194 @@
# Phase 11: MVP Closeout and Release Readiness
## Goal
Turn the current packaged OpenPets MVP into a clear, repeatable release-readiness checkpoint.
This phase should consolidate the validation work from Phase 10A-10E into one broader MVP closeout pass: document exactly what is ready, what remains unverified, how to manually validate the package, and fix any small readiness gaps found while doing that review.
## Non-goals
- No public distribution release.
- No signing, notarization, installer generation, auto-update, or publishing.
- No new agent integrations beyond the current Claude-focused MVP.
- No major UI redesign or new onboarding/product feature.
- No Windows/Linux implementation work beyond documenting unverified status and known follow-up.
- No broad refactors unless required to make existing validation reliable.
## User-visible/manual outcome
The user has one authoritative MVP readiness guide and can run one documented local package validation flow to decide whether the current macOS unpacked package is MVP-ready for local dogfooding.
If small readiness bugs are found during this pass, they are fixed in this phase. Larger items are explicitly documented as post-MVP/release follow-up instead of being silently expanded into this phase.
## Acceptance criteria
- `docs/mvp-validation.md` becomes the authoritative MVP closeout checklist rather than a Phase 10A-centric note.
- The checklist clearly separates:
- automated validation commands,
- macOS manual validation steps,
- cross-platform unverified gaps,
- signing/notarization/distribution deferrals,
- cleanup/uninstall guidance,
- known limitations and post-MVP follow-ups.
- The docs capture the current packaged Claude command expectations using `node` plus `app.asar.unpacked` paths.
- The docs make clear that Claude configuration can contain absolute packaged-app paths and must be replaced/removed after moving or deleting the app.
- Package validation remains local-only and does not mutate real Claude settings during automated tests.
- Any small readiness issues discovered while reviewing the validation path are fixed if they are low-risk and fit this phase.
- Any larger readiness issues are recorded as known limitations/follow-ups, not implemented silently.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes after tests/checks, so manual package validation uses fresh package output.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `docs/mvp-validation.md`
- Rework into MVP closeout/readiness guide.
- `docs/phases/phase-11-mvp-closeout-release-readiness.md`
- This phase spec, Oracle reviews, and dispositions.
- Potentially small targeted package/test/doc files if validation reveals a contained readiness gap.
## Technical approach
1. Audit the current MVP validation doc against the current package behavior from Phase 10A-10E.
2. Reorganize `docs/mvp-validation.md` into a release-readiness structure:
- status summary,
- automated command checklist,
- macOS packaged-app manual checklist,
- Agent Setup/Claude packaged command expectations,
- cleanup/rollback notes,
- platform/signing limitations,
- post-MVP follow-up list.
3. Run the full local validation commands in this order, because desktop package tests may delete `apps/desktop/dist-electron` while exercising the cleanup helper:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
4. If validation finds small issues that are directly related to readiness clarity or package contract reliability, fix them in scope. Examples in scope:
- documentation corrections,
- missing package contract assertions,
- tiny validation-script fixes that keep existing behavior unchanged.
5. If validation finds behavior bugs, cross-platform failures, signing/installer/public distribution needs, or Claude workflow changes, record them as known limitations/follow-ups or ask the user before expanding scope.
6. Send the resulting diff and validation results to Oracle for implementation review.
## Risks and tradeoffs
- A closeout pass can easily become an unbounded bug-fix phase. Scope is intentionally limited to documentation clarity and small readiness fixes.
- Cross-platform package behavior remains unverified without Windows/Linux machines. The honest MVP outcome is to document that gap, not claim support that was not tested.
- The current package is unsigned and uses absolute paths for packaged Claude commands. That is acceptable for local MVP validation but not for public distribution.
- Generated app icon quality is temporary because it is derived from a 64×64 source asset.
## Security/privacy notes
- Automated tests and package checks must keep using temp fixtures and must not modify real Claude settings or real user OpenPets state.
- MVP docs should warn users before they use Agent Setup actions that mutate Claude settings.
- Speech/privacy rules from earlier phases remain unchanged: short safe pet messages only, no code/logs/secrets/URLs/path-like content.
- Cleanup guidance should distinguish OpenPets app data from Claude configuration so users do not assume deleting the app removes agent integrations.
## Test/check plan
Run in this order:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
If any targeted code/test changes are made, also run the most specific affected package checks first where useful.
## Manual verification guide
Manual verification is provided after implementation. It should ask the user to run the revised `docs/mvp-validation.md` macOS checklist against the packaged app and confirm whether the MVP closeout passes locally.
The revised checklist must explicitly cover:
1. Launching the packaged app from `apps/desktop/dist-electron/`.
2. Tray icon/menu presence.
3. Default pet show/hide, pause/resume, drag, and persistence basics.
4. Onboarding presence/completion or documented reset path.
5. Pet Manager installed/catalog state rendering.
6. Settings preference toggle and restart persistence.
7. Local IPC smoke with `pnpm --filter @open-pets/client smoke:status` while the packaged app is running.
8. Agent Setup preview showing bundled `node .../app.asar.unpacked/...` commands, not unpublished `npx -y @open-pets/*` commands.
9. Claude configure/install as an optional mutating step only, with backups and remove/uninstall cleanup called out.
10. Quitting OpenPets from the tray.
## MVP scope statement
Phase 11 validates a macOS local `--dir` dogfooding checkpoint only.
The readiness guide must clearly document these prerequisites and limitations:
- `pnpm` workspace checkout is required for developer validation commands.
- Packaged Claude bundled commands require `node` on Claude's PATH.
- The package is unsigned and not notarized.
- Windows/Linux package behavior, installers, auto-update, and public distribution are unverified/deferred.
- Claude settings may contain absolute paths into the packaged app and should be replaced/removed after moving or deleting the app.
- OpenPets app data cleanup and Claude config cleanup are separate.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Run `pnpm test` and `pnpm check` before `pnpm package:desktop:dir`, because tests may delete package output.
- Bound “small readiness fixes” with examples and require deferral/user approval for larger behavior/platform/signing/Claude workflow changes.
- Make the manual checklist explicit in the spec/docs.
- Separate safe Agent Setup preview validation from optional mutating Claude configure/install steps.
- State MVP scope honestly as macOS local `--dir` dogfooding only.
- Include prerequisites and limitations: Node on PATH for Claude bundled commands, workspace/pnpm requirement, unsigned warnings, absolute Claude paths, ASAR unpacked resources.
Nice-to-have feedback:
- Add a concise readiness status table.
- Include artifact identity details such as output path, app version, date/commit/manual tester fields.
- Add cleanup/data-loss notes distinguishing OpenPets app data from Claude config.
Verdict: not implementation-ready until command-order, checklist, and scope-boundary issues are fixed.
## Oracle feedback disposition
Fixed:
- Validation command order now runs `pnpm test`, `pnpm check`, then `pnpm package:desktop:dir`.
- In-scope small readiness fixes are bounded to docs, package contract assertions, and tiny validation-script fixes; larger behavior/platform/signing/Claude workflow changes require deferral or user approval.
- Manual checklist requirements are explicit in the spec.
- Claude Agent Setup preview is separated from optional mutating Claude configure/install validation.
- MVP scope is explicitly macOS local `--dir` dogfooding only.
- Prerequisites and limitations are explicitly listed.
Accepted for implementation:
- Add readiness status table.
- Add artifact identity fields where useful.
- Add cleanup/data-loss notes distinguishing OpenPets app data from Claude config.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Add a commit/SHA field to Artifact identity when doing an actual release-readiness run.
- Add typical macOS app-data path as a convenience while keeping the logged-path guidance.
Verdict: implementation-ready for manual user verification. The docs are honest about macOS-only local `--dir` readiness, clearly separate safe vs mutating Claude steps, document absolute packaged Claude paths and cleanup/data-loss risks, and preserve the workflow gate before closing MVP readiness.
## Oracle implementation feedback disposition
Accepted:
- Commit/SHA artifact fields can be filled during an actual release-readiness run.
- Typical platform app-data paths can be added as future documentation polish; the current logged-path guidance is the source of truth and avoids platform-path drift.

View file

@ -0,0 +1,253 @@
# Phase 12: Actual Pet Animation Polish
## Goal
Replace the placeholder-feeling built-in pet experience with an actual animated pet renderer and premium compact chat bubble that feel alive in the packaged app.
This phase should make the default OpenPets experience look like a real desktop companion instead of a CSS/wireframe prototype, while preserving the stable packaging, IPC, Claude, and pet-install safety work already completed.
## Non-goals
- No public marketplace/catalog redesign.
- No signing, notarization, installer, or distribution changes.
- No new agent integrations.
- No broad Pet Manager/Agent Setup UI redesign unless needed to preview the actual pet.
- No complex physics simulation.
- No audio.
- No unvalidated remote pet format expansion that weakens install safety.
## User-visible/manual outcome
When the user launches OpenPets, the default pet appears as an actual animated sprite/visual companion, not a simple CSS mascot or static image.
The pet should:
- idle with a high-quality loop,
- switch to directional drag/move animation based on mouse drag direction,
- react visibly to pause/resume,
- show premium compact speech/reaction bubbles cleanly,
- stay transparent/framed like a desktop pet,
- remain draggable and non-disruptive,
- package correctly in the macOS local app.
## Acceptance criteria
- A real bundled default pet visual asset exists in `apps/desktop/assets/` or another packaged desktop asset location.
- Bundled pet asset contract is explicit:
- source file: `apps/desktop/assets/default-pet-spritesheet.webp`, copied from the real catalog material for Claude (`https://zip.openpets.dev/pets/claude-f187e74a/claude.zip` / `web/public/pets/claude-f187e74a/spritesheet.webp`),
- no remote runtime art fetch,
- known frame layout/dimensions documented in code comments or constants: 1536×1872, 8 columns × 9 rows, 192×208 frames,
- at minimum supports `idle`, `run-left`, and `run-right` visual states,
- no SVG-coded/generated substitute for the default pet.
- The built-in default pet renderer uses the asset instead of the CSS-only mascot.
- The pet renderer supports at least an idle animation loop for the bundled pet.
- Dragging the pet changes animation/direction based on mouse movement:
- dragging right uses a right-facing/running-right visual state,
- dragging left uses a left-facing/running-left visual state,
- stopping drag settles back to idle without jitter.
- Drag-direction detection does not rely on renderer pointer events inside `-webkit-app-region: drag`.
- Installed-pet rendering uses the same current catalog sprite contract as the built-in default pet: 1536×1872 WebP, 8 columns × 9 rows, 192×208 frames, with idle/run-right/run-left rows animated instead of showing the full spritesheet.
- Pause/resume visibly affects animation without breaking window transparency or input behavior.
- Speech/reaction bubble styling is polished enough for MVP: premium compact text, readable typography, subtle depth, clean pointer, constrained dimensions, gentle entrance/exit, not huge, not wireframe, and does not expose unsafe message content beyond existing filtering.
- Pet window sizing/clamping accounts for the actual visual asset and bubble space.
- The tray icon and packaged app icon behavior remain unchanged.
- Package contract checks cover the new bundled pet asset(s).
- Package contract checks verify the source asset exists, is non-empty, and remains covered by packaged `assets/**` inclusion.
- `pnpm test` passes.
- `pnpm check` passes.
- `pnpm package:desktop:dir` passes.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/assets/`
- Add bundled default pet sprite/asset file(s).
- `apps/desktop/src/pet-window.ts`
- Replace CSS-only built-in pet with asset-backed rendering.
- Add animation-state CSS/JS for bundled pet.
- Add drag-direction animation state handling driven from safe main-process window movement signals, not renderer pointer events in draggable regions.
- Polish bubble/reaction presentation.
- `apps/desktop/pet-preload.cjs`
- Optional narrow preload for pet windows if needed to receive main-process motion events and update DOM state without enabling Node in the renderer.
- `apps/desktop/src/built-in-pet.ts`
- Document or reference bundled pet asset expectations if useful.
- `apps/desktop/src/display.ts`
- Adjust default pet window sizing if the actual pet needs more space.
- `apps/desktop/src/check-packaging-contract.ts`
- Assert bundled pet asset(s) are present and packaged.
- `docs/mvp-validation.md`
- Update manual validation checklist to include actual animated pet quality checks.
- `docs/phases/phase-12-actual-pet-animation-polish.md`
- This phase spec, reviews, and dispositions.
## Technical approach
1. Choose a safe MVP asset strategy:
- Use a repository-owned bundled WebP/PNG sprite asset from the existing catalog materials.
- Do not use SVG-coded/generated substitute art for the default pet.
- Initial target contract: one local 1536×1872 sprite sheet with 8 columns × 9 rows, 192×208 frames, and rows/states for `idle`, `run-left`, and `run-right`.
- Do not fetch remote art at runtime.
2. Implement asset-backed built-in pet rendering:
- Keep the pet window sandboxed/context-isolated.
- Keep CSP strict and allow only the needed local/data image sources (`img-src file: data:` for file-backed images).
- If renderer-side state JS is required, prefer a narrow preload listening for whitelisted IPC messages. Do not enable Node integration, remote scripts, eval, or broad renderer privileges.
- Preserve transparent frameless always-on-top behavior.
3. Add MVP animation:
- Use CSS sprite/background animation or a small controlled frame loop.
- Avoid heavy dependencies.
- Respect pause/resume with `animation-play-state` or equivalent.
- During drag, infer horizontal drag direction from main-process `BrowserWindow` movement deltas because Electron draggable regions do not reliably deliver renderer pointer events.
- Send only whitelisted motion states (`idle`, `run-left`, `run-right`) to the pet renderer, via narrow preload IPC or another bounded safe bridge.
- Use simple thresholding/debouncing so the pet does not flicker between left/right on tiny movements.
- Return to idle after drag stops or after a short no-movement timeout.
- Add `prefers-reduced-motion` handling where practical.
4. Polish the pet and chat bubble presentation:
- Better shadow/depth around the pet image.
- Speech bubble with small premium typography, constrained width/height, subtle entrance/exit, pointer styling, and layered shadow instead of harsh borders.
- Bubble copy should feel like a compact companion status, not a toast/card: short line length, balanced wrapping, soft radius, optical alignment to the pet, and no giant empty surface.
- Bubble content must be rendered as escaped text/textContent only, never `innerHTML` from message content.
- Apply shared bubble styling to both built-in and installed pet windows so the default and installed experiences do not diverge badly.
- No `transition: all`; animate specific transform/opacity properties only.
5. Preserve installed pet behavior:
- Current installed catalog pets provide `spritesheet.webp` using the 1536×1872 / 8×9 / 192×208 contract.
- Render installed pets through the same CSS sprite-frame approach as the built-in default pet, not as a full-sheet image.
- Do not show a decorative fallback square/box behind valid installed pets.
- If future metadata parsing is added, keep it strict and backward-compatible.
6. Update contract checks/docs and run full validation.
## Risks and tradeoffs
- Art quality can consume too much time. This phase should improve the default from wireframe to delightful MVP, not solve final branding.
- Sprite animation format changes can break installed pets if not backward-compatible. Phase 12 assumes the current catalog/v1 1536×1872 / 8×9 / 192×208 spritesheet contract; future custom formats need explicit validation/metadata work.
- `-webkit-app-region: drag` prevents reliable renderer pointer events. Drag animation must be driven from main-process window movement or a narrow custom drag architecture.
- Window transparency, draggable regions, and speech bubbles are platform-sensitive; keep the implementation simple and test packaged output.
- Chat bubble polish can expand into broad UI redesign. Keep scope to the pet window bubble only.
- Drag-direction animation can be jittery if based on every pointer event. Use thresholds and smoothing rather than instant direction flips.
- Larger pet/bubble sizing can move the pet unexpectedly; clamp to work area and avoid stealing focus.
## Security/privacy notes
- Bundled pet assets must be local repository assets; no runtime remote fetch.
- Installed pet safety rules remain: no script execution, zip-slip prevention, file limits, size limits.
- Speech safety rules remain unchanged: short messages only; no code/logs/secrets/URLs/path-like content.
- Renderer CSP must remain restrictive.
- Chat bubble message content must use escaped text/textContent only and must not introduce `innerHTML` injection paths.
## Test/check plan
Run:
```bash
pnpm test
pnpm check
pnpm package:desktop:dir
```
Also run focused desktop checks during implementation if package contract or pet-window code changes.
## Manual verification guide
Manual verification is provided after implementation. It should include:
1. Run `pnpm dev:desktop` and confirm the default pet is an actual animated visual, not CSS/wireframe.
2. Confirm the pet remains transparent, frameless, draggable, and non-focus-stealing.
3. Drag the pet left and right and confirm the animation/direction changes with drag direction, then settles back to idle without flicker.
4. Confirm pause/resume visibly pauses/resumes animation.
5. Trigger or inspect speech/reaction bubble behavior and confirm it feels premium: compact, readable, visually attached to the pet, no oversized wireframe card, no jarring animation.
Suggested speech command while OpenPets is running:
```bash
pnpm --filter @open-pets/client smoke:say "Working on it" thinking
```
Suggested reaction command:
```bash
pnpm --filter @open-pets/client smoke:react success
```
6. If a catalog/installed pet is available, set it as default and confirm static installed-pet rendering still works with the shared polished bubble.
7. Run `pnpm package:desktop:dir`, launch the packaged app, and repeat the default pet checks.
## Oracle plan review
Reviewed by Oracle.
Blockers:
- Drag-direction plan was not implementable as initially written because the current pet window uses `-webkit-app-region: drag`, which makes renderer pointer events unreliable, and there was no preload/IPC bridge.
Should-fix feedback:
- Define the bundled pet asset contract: filenames, format, dimensions/frame layout/states, ownership/source, and fallback behavior.
- Be explicit about CSP and avoid broad renderer scripting privileges.
- Require bubble text to use escaped text/textContent only.
- Clarify that polished bubble styling applies to installed pets too.
- Add concrete manual bubble triggers using client smoke commands.
- Add manual verification for installed-pet fallback/static rendering.
- Specify package contract checks for new assets in ASAR-era packaging.
- If changing default pet window size, verify clamping/persistence.
Nice-to-have feedback:
- Prefer CSS sprites or animated image formats over custom JS frame loops unless JS is needed for drag state.
- Add lightweight asset checks for existence, size, and dimensions if easy.
- Add `prefers-reduced-motion` support.
- Use real catalog pet material rather than generated/SVG-coded art when available.
Verdict: not implementation-ready until drag-direction architecture and asset/CSP/manual-verification requirements are tightened.
## Oracle feedback disposition
Fixed:
- Drag-direction architecture now explicitly avoids renderer pointer events in draggable regions and uses main-process window movement deltas plus a narrow safe bridge/preload if needed.
- Added bundled pet asset contract requirements, including local checked-in WebP source, known frame layout/states, and no SVG-coded/generated substitute.
- Added CSP requirements for file/data image sources and no Node integration/remote/eval/broad renderer privileges.
- Required bubble content to be escaped/text-only and shared across built-in and installed pet windows.
- Added concrete manual `smoke:say` and `smoke:react` triggers.
- Added installed-pet fallback/static rendering manual verification.
- Added package contract expectations for source asset existence/non-empty and packaged `assets/**` inclusion.
- Added clamp/persistence verification if window sizing changes.
Accepted:
- Prefer CSS sprites or animated image formats when practical.
- Add lightweight asset checks where easy.
- Add `prefers-reduced-motion` handling where practical.
- Prefer real catalog pet material over generated art.
## Oracle implementation review
Reviewed by Oracle after implementation.
Blockers: none.
Should-fix: none before manual verification.
Key checks:
- Drag-direction architecture uses main-process window movement, not renderer pointer events in `-webkit-app-region: drag`.
- Pet preload is narrow: only listens for whitelisted `openpets:pet-motion` states and mutates `dataset`.
- Renderer remains sandboxed/context-isolated with Node disabled.
- Bubble content is escaped text, not unsafe HTML.
- Installed pet fallback remains static/safe with shared bubble styling.
- Packaging config includes `pet-preload.cjs` and assets; contract checks cover source asset/preload/config.
Nice-to-have feedback:
- Add an ASAR-content/package-output assertion for `pet-preload.cjs` and `default-pet-spritesheet.webp` later if convenient.
- If manual testing shows drag jitter, tune the `3px` threshold / `180ms` idle timeout.
- Future installed-pet animation metadata remains out of Phase 12.
Verdict: implementation is acceptable for manual verification. Focus manual testing on packaged sprite loading, left/right drag animation, idle settle, bubble quality, pause/resume, installed-pet fallback, and no focus/drag regressions.
## Oracle implementation feedback disposition
Accepted:
- ASAR-content assertions can be added as future package hardening; current source/config/package validation passed.
- Additional bundled asset hygiene checks can be added if bundled pet assets become more complex.
- Drag threshold/idle timing will be tuned only if manual testing shows jitter.
- Richer installed-pet animation metadata is a future phase, not Phase 12.

View file

@ -0,0 +1,240 @@
# Phase 13A: Pet Manager UI/UX Polish
## Goal
Make the Pet Manager feel like the first polished OpenPets desktop window and establish a reusable visual direction for later Settings, Agent Setup, and Onboarding polish.
This phase focuses on one window only: Pet Manager.
## Non-goals
- No Settings, Agent Setup, or Onboarding redesign.
- No new install/remove/default product behavior beyond small UI affordances needed for clarity.
- No new pet catalog backend, marketplace, or publishing flow.
- No remote image rendering in the renderer.
- No framework migration; keep the current vanilla preload-driven UI.
- No weakening zip/catalog/install safety.
- No public distribution/signing work.
## User-visible/manual outcome
Opening **Manage Pets** from the tray shows a premium compact desktop UI instead of a wireframe list.
The user should be able to quickly understand:
- which pet is currently default,
- which pets are installed,
- which catalog pets can be installed,
- what actions are safe/available,
- whether catalog data is live, fixture, loading, empty, or errored.
## Acceptance criteria
- Pet Manager copy no longer says or feels like placeholder/wireframe.
- Pet Manager has a polished header, clear current-default summary, installed-pets section, catalog/search section, and error/status surfaces.
- Installed cards clearly distinguish default, built-in/protected, broken, and removable pets.
- Catalog cards clearly distinguish installable, installed, default, and unavailable/broken states.
- Buttons have clear hierarchy and disabled/busy states; no tiny ambiguous controls.
- Search has a polished empty state when no catalog pets match.
- Catalog status explains live vs fixture vs error without looking like debug text.
- The design uses consistent spacing, typography, radii, shadows, badges, and compact desktop proportions.
- No external images are introduced into the Pet Manager renderer.
- Pet install, remove, and set-default behavior remains unchanged and safe.
- Pet Manager can still render if catalog fetch falls back to fixture or fails.
- Pet Manager rendering continues to use DOM APIs and `textContent` for all catalog/state strings; no `innerHTML` for catalog-provided content.
- No CSP `img-src` expansion, no `<img>`, and no CSS `background-image` using catalog preview URLs in Phase 13A.
- Existing visible placeholder copy is removed from Pet Manager window definitions and user-facing text.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- If shared task-window styles are changed, verify Settings/Agent Setup/Onboarding are not obviously broken.
- Designer review is completed before final manual verification.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Update Pet Manager HTML structure and Pet Manager-specific/shared styling.
- Remove placeholder Pet Manager description.
- `apps/desktop/preload.cjs`
- Refactor `renderPetManager` / `renderCatalog` card DOM for polished UI states.
- Add small helper functions for status labels, card metadata, action rows, and empty states.
- `docs/phases/phase-13a-pet-manager-ui-polish.md`
- This phase spec, reviews, and dispositions.
- Potentially `docs/mvp-validation.md`
- Add a short Pet Manager visual verification note if useful.
## Technical approach
1. Keep scope to Pet Manager:
- Require Pet Manager selectors under `body[data-openpets-view="pet-manager"]` unless intentionally changing shared task-window tokens.
- Avoid broad restyling of Settings/Agent Setup/Onboarding unless shared base styles must be adjusted carefully.
- Remove the existing placeholder copy from `taskWindowDefinitions.pet-manager.description` and visible Pet Manager text.
2. Improve information architecture:
- Add a hero/header area with concise product copy.
- Add a current-default summary card derived from `state.preferences.defaultPetId` and installed pet state.
- Keep installed pets and catalog as separate sections.
3. Polish cards:
- Installed cards: pet name, id/source metadata, status badges, concise description, broken reason if present, primary/default action, secondary/remove action.
- Catalog cards: pet name, id metadata, description, installed/default badges, install or set-default/remove actions.
4. Improve state surfaces:
- Catalog source/status pill for live/fixture/error.
- Empty installed/catalog search states with plain helpful copy.
- Inline global error surface that is visually distinct but not alarming for non-destructive errors.
5. Improve interaction polish:
- Button hierarchy: primary install/set-default, secondary remove/protected.
- Busy state remains button-level/card-level without changing backend operation semantics.
- Use specific CSS transitions only; no `transition: all`.
- Add subtle card hover/focus/press feedback without distracting animations.
6. Preserve security constraints:
- No remote images in Pet Manager renderer.
- Do not add `img-src` to the Pet Manager CSP, do not add `<img>`, and do not use catalog preview URLs as CSS backgrounds in this phase.
- Continue using textContent/DOM APIs for user/catalog strings.
- Keep renderer CSP restrictive.
## Risks and tradeoffs
- Pet Manager currently shares task-window CSS with other windows. Over-broad changes could accidentally regress Settings/Agent Setup/Onboarding.
- Preview thumbnails would be desirable but can create CSP/cache/security work. Defer image previews unless implemented through a safe later design.
- Pure vanilla DOM UI can become messy. Keep helpers small and avoid a framework rewrite.
- Catalog can contain many pets; keep cards reasonably compact and avoid heavy DOM work or animations.
## Security/privacy notes
- Pet Manager still must not execute pet content or scripts.
- Catalog strings must be inserted with `textContent`, not `innerHTML`.
- Install/remove/default actions must keep the existing IPC allowlist and validation.
- No remote image URLs should be added to CSP for this phase.
- No `innerHTML` should be used for catalog-provided or state-provided strings.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
If shared styles or packaging-sensitive files change unexpectedly, also run:
```bash
pnpm check
```
## Manual verification guide
Manual verification is provided after implementation. It should include:
1. Run `pnpm dev:desktop`.
2. Open **Manage Pets** from the tray.
3. Confirm the window feels polished, compact, and not wireframe.
4. Confirm current default pet is obvious.
5. Search the catalog and confirm empty/search states look good.
6. Confirm live/fixture/error catalog status is understandable; simulate unavailable catalog only if feasible without expanding scope.
7. Install a catalog pet if available and confirm busy state during install.
8. Set an installed catalog pet as default.
9. Remove a removable pet and confirm busy state during remove.
10. Confirm broken/protected/default button states are understandable.
11. Open Settings/Agent Setup/Onboarding quickly to confirm shared styles were not obviously broken if shared styles changed.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Tighten style isolation under `body[data-openpets-view="pet-manager"]` unless intentionally changing shared tokens.
- Add explicit acceptance that catalog/state strings use DOM APIs/`textContent`, no `innerHTML` for catalog content.
- Add manual verification for catalog unavailable/error/fixture status if feasible, busy install/remove states, set default, remove, and shared window regression check.
- Make “no remote image rendering” concrete: no CSP `img-src` expansion, no `<img>`, no catalog preview CSS backgrounds.
- Explicitly remove existing Pet Manager placeholder copy.
Nice-to-have feedback:
- Add a small Pet Manager visual checklist to `docs/mvp-validation.md`.
- Define a few grouped design tokens/comments without building a full design system.
- Add compact empty states for no installed pets beyond built-in and no catalog matches.
- Keep helper extraction modest; avoid a mini framework.
Verdict: implementation-ready after small spec tightening.
## Oracle feedback disposition
Fixed:
- Added selector isolation requirement under `body[data-openpets-view="pet-manager"]`.
- Added explicit `textContent`/DOM API requirement and `innerHTML` prohibition for catalog/state strings.
- Added concrete no-remote-image constraints.
- Added placeholder-copy removal as an explicit implementation item.
- Expanded manual verification for catalog status, busy states, set-default/remove actions, and shared-window regression checks.
Accepted:
- Add Pet Manager visual checklist to `docs/mvp-validation.md` if implementation changes warrant it.
- Use modest grouped design tokens/comments only if helpful.
- Add compact empty states for catalog/no-extra-installed states.
- Keep helper extraction modest.
## Designer implementation review
Reviewed by Designer after implementation.
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Make Pet Manager empty states span the full grid width.
- Make the current default card heading slightly larger to reinforce hierarchy.
Verdict: approved. The visual direction is a substantial step up from wireframe and establishes a good baseline for future windows.
## Designer implementation feedback disposition
Fixed:
- Empty states now span all grid columns.
- Current default card heading is larger than regular card headings.
## Oracle implementation review
Reviewed by Oracle after implementation and follow-up fixes.
Initial should-fix feedback:
- Restore remove affordance for a removable current default pet.
- Show broken state on catalog cards.
- Avoid appending empty actions rows for current-default catalog cards.
- Fix current-default remove busy-state restore so a newly rendered protected default does not get an enabled dead Protected button.
Final review:
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Manually verify Settings/Agent Setup/Onboarding because shared button/card/search styles changed.
- Consider shortening catalog error pill text if it feels too debuggy.
- Future: centralize small DOM helpers if Pet Manager grows further.
Verdict: approved for manual verification. Removable defaults remain actionable, broken catalog states are visible, empty action rows are avoided, busy-state restoration is fixed, and CSP/textContent/no-remote-image constraints remain intact.
## Oracle implementation feedback disposition
Fixed:
- Current default card now includes badges, broken reason, and Remove/Protected action.
- Catalog cards now show Broken badge and broken reason for installed broken pets.
- Empty catalog card action rows are no longer appended.
- Current-default remove busy restore no longer runs after successful re-render, preventing stale enabled Protected buttons.
Accepted:
- Shared-window visual smoke check is included in manual verification.
- Catalog error pill copy can be adjusted later if it feels too debug-like in manual testing.
- DOM helper centralization is deferred until more UI windows are polished.

View file

@ -0,0 +1,270 @@
# Phase 13B: Onboarding Greeting Polish
## Goal
Turn the first onboarding screen into a polished OpenPets greeting inspired by `lfs/greeting.png`, without copying the screenshot literally.
This phase should create a native onboarding welcome experience that feels premium, playful, and consistent with the Phase 13A Pet Manager polish direction.
## Non-goals
- No full onboarding redesign beyond the greeting/welcome step unless a small structural change is required to keep navigation coherent.
- No Agent Setup, Settings, or Pet Manager changes.
- No copying the screenshot pixel-for-pixel.
- No using the screenshot as the final UI background.
- No remote image loading.
- No new onboarding business logic.
- No new pet install/configuration behavior.
## User-visible/manual outcome
On first launch, the onboarding welcome screen should feel like a real product greeting:
- clear OpenPets identity,
- friendly “AI coding companion” positioning,
- playful pet/mascot atmosphere,
- strong primary Next action,
- subtle progress dots,
- polished copy that is not generic placeholder text.
## Acceptance criteria
- Onboarding step 0 has a polished greeting layout inspired by `lfs/greeting.png` but implemented as native HTML/CSS.
- The welcome copy is concise and product-quality; avoid awkward/generic wording.
- The greeting uses available local assets only. Current discovered local assets are:
- `apps/desktop/assets/default-pet-spritesheet.webp`
- `apps/desktop/assets/tray-icon.png`
- `lfs/greeting.png` as reference only, not packaged directly unless explicitly approved.
- The implemented greeting may use extracted local WebP assets copied from `web/public` into `apps/desktop/assets/`:
- `apps/desktop/assets/onboarding-logo.webp` from `web/public/openpets.webp`
- `apps/desktop/assets/onboarding-pets.webp` from `web/public/petland.webp`
- `lfs/greeting.png` must not appear in runtime code, packaged assets, CSS URLs, or CSP assumptions.
- Packaged image assets must be embedded as data URLs generated from local packaged files; onboarding CSP must remain `img-src data:` with no remote or `file:` image sources.
- If a hero image/illustration is needed, use a safe local packaged asset or a CSS/native composition; do not fetch remote images.
- Onboarding CSP remains restrictive; no remote image/script/style sources.
- Dynamic text continues to use DOM APIs/textContent in preload.
- Existing onboarding steps still work: Welcome → Pets → Agents → Ready → Finish.
- Existing onboarding buttons still work.
- Window remains usable at current task-window size and does not require custom native titlebar/window controls.
- Greeting fits in the increased 900×760 default task window without clipping the primary action, and remains usable at the 720×520 minimum window size.
- Keyboard/tab access still reaches Next and later Finish controls.
- Progress dots/steps still communicate the current step accessibly.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- If any runtime asset is added/referenced, `pnpm package:desktop:dir` passes or packaged asset loading is otherwise verified.
- If shared task-window styles are touched, Pet Manager, Settings, and Agent Setup are manually checked for obvious visual regressions.
- Designer review is completed before manual verification.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Update onboarding welcome markup and onboarding-specific scoped styles.
- Keep styles under `body[data-openpets-view="onboarding"]` where possible.
- `apps/desktop/preload.cjs`
- Keep existing step navigation; adjust only if new welcome markup needs small class/state handling.
- `apps/desktop/assets/`
- Add `onboarding-logo.webp` and `onboarding-pets.webp` if using the real WebP greeting assets. Do not package `lfs/greeting.png` as-is.
- `docs/phases/phase-13b-onboarding-greeting-polish.md`
- This phase spec, reviews, and dispositions.
## Technical approach
1. Use `lfs/greeting.png` as art direction, not implementation:
- light airy background,
- pixel/game feel,
- strong OpenPets brand block,
- mascot/pet visual area,
- progress dots and primary Next button.
- The file must remain reference-only and should not be copied into `apps/desktop/assets` or loaded by the app.
2. Build native onboarding welcome UI:
- Keep the existing task window shell and Electron titlebar.
- Use scoped onboarding CSS for the welcome step.
- Use bundled local WebP assets when they materially improve fidelity to the reference.
- Generate data URLs from packaged local asset files in the main process before creating the onboarding data URL, avoiding fragile `data:` document → `file:` image loading.
3. Copy direction, not copy text:
- Proposed headline direction: “Your AI coding companion” or similar.
- Proposed body direction: “OpenPets lives in your tray and gives your coding agents a friendly desktop companion.”
- Keep copy short and human.
4. Preserve onboarding flow:
- Step indicators still reflect current step.
- Next continues to Pets.
- Existing later steps remain functional even if visually less polished for now.
5. Preserve security:
- No remote images.
- If local packaged image assets are used, onboarding CSP must include only `img-src data:` and no remote or `file:` image sources.
- No `innerHTML` for dynamic text.
- Keep CSP restrictive.
## Risks and tradeoffs
- A single polished greeting may make later onboarding steps feel less polished. Acceptable for this phase; the goal is to confirm the welcome styling direction first.
- Using `lfs/greeting.png` directly would be fast but wrong for product UI: it includes screenshot/window chrome and fixed text. Build native UI instead.
- Without additional uploaded hero art, the visual will be inspired rather than identical. If user provides separated logo/mascot/scene assets later, a follow-up can swap them in.
- Data-URL task windows plus local file assets are easy to break in packaged apps. If assets are used, embed them as data URLs generated from packaged local files and verify package output.
## Security/privacy notes
- No remote asset loading.
- No user data or agent configuration is changed by this phase.
- Onboarding completion semantics stay unchanged.
- Renderer remains sandboxed/context-isolated through the existing task-window setup.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
If shared task-window styles are changed unexpectedly, also run:
```bash
pnpm check
```
If runtime image assets are added or referenced, also run:
```bash
pnpm package:desktop:dir
```
## Manual verification guide
Manual verification is provided after implementation. It should include:
1. Run `pnpm dev:desktop`.
2. Reset onboarding state if needed by deleting the logged OpenPets app data state file/directory.
3. Confirm the first onboarding screen looks like a polished greeting inspired by `lfs/greeting.png`, not a literal screenshot paste.
4. Confirm copy feels good and not awkward.
5. Confirm the primary action is visible and not clipped at the default task-window size.
6. Confirm keyboard tab order reaches Next and later Finish controls.
7. Confirm progress dots/steps still show the current step clearly.
8. Confirm Next advances to Pets, then Agents, then Ready.
9. Confirm Finish still completes onboarding.
10. If shared styles changed, quickly inspect Pet Manager, Settings, and Agent Setup for obvious visual regressions.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Clarify that `lfs/greeting.png` is reference-only and must not appear in runtime code, packaged assets, CSS URLs, or CSP assumptions.
- Clarify asset strategy and CSP requirements if existing local packaged assets are used.
- Add package/manual verification if runtime assets are added or referenced.
- Add shared-style regression criteria because `createTaskWindowStyles()` is shared.
- Add fit/accessibility checks for default/minimum window sizes, keyboard/tab access, and progress dots/steps.
Nice-to-have feedback:
- Require designer review against the direction of `greeting.png`, not literal fidelity.
- Add a short note to `docs/mvp-validation.md` if this becomes part of first-run validation.
- Prefer CSS/native composition unless asset use is strongly justified.
Verdict: not implementation-ready until asset/CSP/package and shared-style verification requirements are tightened.
## Oracle feedback disposition
Fixed:
- Spec now states `lfs/greeting.png` is reference-only and must not be loaded or packaged.
- Local WebP assets are allowed when copied from `web/public` into desktop assets, embedded as data URLs, covered by explicit `img-src data:`, and package-verified.
- Added package validation requirement if runtime image assets are added/referenced.
- Added shared-window regression manual checks if shared styles are touched.
- Added fit/accessibility checks for default window size, keyboard access, and progress indicators.
Accepted:
- Designer review should compare against the reference direction, not literal screenshot fidelity.
- `docs/mvp-validation.md` can be updated if implementation makes onboarding greeting part of MVP validation.
- CSS/native composition is preferred unless asset use is clearly worth it.
## Designer implementation review
Reviewed by Designer after implementation.
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Add a small transition to soften the visual jump between the light welcome step and later dark onboarding steps.
Verdict: approved. The native CSS composition captures the direction of `lfs/greeting.png` without copying the screenshot or adding asset/CSP risk.
## Designer implementation feedback disposition
Fixed:
- Added a reduced-motion-aware body color transition.
## Oracle implementation review
Reviewed by Oracle after implementation and follow-up fixes.
Initial should-fix feedback:
- Ensure hidden panels stay hidden because the welcome hero display rule appears after the generic hidden rule.
- Add `aria-current="step"` to the active progress indicator.
Final review:
Blockers: none.
Should-fix: none.
Nice-to-have feedback:
- Manually verify default-size fit and 720×520 minimum-size usability in real Electron rendering.
- Manually inspect Pet Manager, Settings, and Agent Setup because shared body/button styles changed.
- Future: replace emoji pet tokens with real brand/mascot assets if provided.
Verdict: approved for manual verification. Hidden panels are protected, progress state has `aria-current`, no runtime image/CSP risk was introduced, and scope remains limited to the onboarding welcome/greeting step.
## Oracle implementation feedback disposition
Fixed:
- Hidden onboarding panels now use `display: none !important` to avoid cascade surprises.
- Active progress indicator now receives `aria-current="step"`, removed from inactive indicators.
- Added reduced-motion guard for body transition/button transforms.
Accepted:
- Manual verification includes default-size fit and shared-window smoke checks.
- Real brand/mascot assets can replace emoji tokens in a later phase if provided.
## Asset-based implementation review
Reviewed by Designer and Oracle after switching from CSS/emoji composition to bundled WebP assets.
Designer verdict: approved.
- Blockers: none.
- Should-fix: none.
- Nice-to-have: cache generated asset data URLs so repeat onboarding opens do not synchronously reread WebP files.
Oracle verdict: approved for manual verification.
- Blockers: none.
- Should-fix: none.
- Nice-to-have: tighten CSP/package assertions, optionally assert packaged asset contents, remove/exclude `.DS_Store`, and manually launch packaged onboarding once.
## Asset-based implementation feedback disposition
Fixed:
- Increased the default task window size to 900×760 while retaining a 720×520 minimum size.
- Cached generated asset data URLs in the main process after the first read.
- Package contract now asserts the onboarding asset filenames and `img-src data:` CSP in `windows.ts`.
Accepted:
- Manual verification should check both the increased default size and minimum-size usability.
- Packaged-app launch verification is useful before release; `pnpm package:desktop:dir` already passes for this phase.

View file

@ -0,0 +1,238 @@
# Phase 13C: Pet Manager Gallery
## Goal
Redesign the Pet Manager into a polished pet installation/gallery screen inspired by `lfs/pets.png`, while preserving the existing real catalog, install, set-default, and remove behavior.
## Non-goals
- No changes to catalog source, pet zip validation, installation security, or state schema.
- No remote image loading beyond existing catalog preview URLs already used by catalog metadata.
- No new pet categories in persisted data; filters can be UI-derived from current pet text/ids.
- No using `lfs/pets.png` as a runtime image or packaged asset.
- No full redesign of Agent Setup, Settings, or onboarding beyond shared task-window sizing/styles if required.
## User-visible/manual outcome
Opening Pet Manager shows a light, polished two-column install page:
- OpenPets brand art at top-left,
- search and simple gallery filter pills,
- install/selectable pet cards with pixel-art previews where available,
- a large right-side selected pet detail panel,
- focused action buttons that install, set default, remove, or indicate selected/protected/broken state,
- existing catalog/install behavior still works.
## Acceptance criteria
- Pet Manager visually follows `lfs/pets.png` direction: light background, two-column gallery/detail layout, pixel-game product feel, rounded cards, strong primary actions.
- `lfs/pets.png` remains reference-only and is not loaded by app code or copied into desktop assets.
- Use existing bundled/local assets where safe, including `apps/desktop/assets/onboarding-logo.webp` for brand identity. Do not embed the large `default-pet-spritesheet.webp` in the Pet Manager data URL.
- Catalog pet preview images may be shown only from validated `CatalogPetV2.preview` values returned by main-process catalog data. The renderer must not independently construct remote image URLs.
- Pet Manager CSP must be exactly scoped for required image sources, e.g. `img-src data: https://openpets.dev`; no broad `https:`, `*`, or `file:` sources.
- Do not use mutable `InstalledPetState.source.preview` for remote rendering unless it is revalidated against the same catalog preview rules.
- Catalog previews are spritesheets; the UI must crop/display a single-frame thumbnail/preview, not show the whole sheet.
- Image failures/offline state must degrade gracefully: no broken image icon, neutral/blank preview surface, and install/default/remove actions remain usable.
- All catalog/state strings are inserted with DOM APIs/textContent, not `innerHTML`.
- Existing Pet Manager operations continue to work:
- install catalog pet,
- set installed pet as default,
- remove non-protected installed pet,
- protected built-in/default pet cannot be removed,
- broken installed pets cannot be selected as default.
- Search continues to filter catalog/gallery pets.
- Quick filters are limited to `All` and `Installed`; do not add category filters like `Cute`, `Helpers`, or `Robots` until backed by real catalog metadata.
- The large detail panel does not include decorative metadata pills like `Free`, `Open source`, or `Works in terminal`; those are unnecessary until backed by real catalog metadata.
- Detail actions are limited to real Pet Manager operations: `Install`, `Set Default`, and `Remove`, with disabled/status variants such as `Selected`, `Protected`, `Broken`, or `Installing…`.
- Keyboard users can tab to search, filter pills, pet cards/actions, and detail actions.
- The Pet Manager opens at 1160×780 and remains usable at 720×520 with vertical scrolling/responsive stacking.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- `pnpm package:desktop:dir` passes because Pet Manager CSP/asset packaging contracts change.
- Designer review is completed before manual verification.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Update Pet Manager markup and scoped CSS.
- Add Pet Manager image CSP for data/local catalog previews as needed.
- `apps/desktop/preload.cjs`
- Replace the current section-card renderer with gallery/detail rendering.
- Keep IPC calls and validation boundaries unchanged.
- `apps/desktop/src/check-packaging-contract.ts`
- Update assertions if the Pet Manager starts depending on bundled image assets/CSP.
- `docs/phases/phase-13c-pet-manager-gallery.md`
- This spec, reviews, and dispositions.
## Technical approach
1. Keep backend/state/install behavior unchanged.
2. Build one normalized UI list from installed pets plus catalog pets:
- installed/default state comes from `OpenPetsStateV1`,
- catalog data comes from `getCatalog()`.
3. Render a left gallery with:
- search,
- filter pills (`All`, `Installed`) derived from installed/default state,
- cards showing thumbnail, name, subtitle, and selected/install state.
4. Render a right detail panel for the selected pet:
- title/description,
- large preview image,
- concise install/default/protected/broken status,
- small preview tiles reusing available image material,
- primary action (`Install`, `Selected`, or `Set Default`) and secondary action (`Remove` or `Protected`) where applicable.
5. Use safe image behavior:
- bundled default uses data URL or local packaged asset strategy already established,
- catalog previews use only validated `CatalogPetV2.preview` URLs from main-process catalog data, scoped to `https://openpets.dev`,
- card/mini preview images crop a single sprite frame from spritesheets using CSS background strategy,
- the detail spotlight animates the pet sprite itself without decorative platform/fallback art,
- image error handling is attached with DOM listeners in preload (`addEventListener("error", ...)`), not inline attributes,
- failures hide the broken image and leave a neutral preview surface.
6. Preserve accessibility:
- cards are buttons or contain focusable buttons,
- selected card uses `aria-pressed` or `aria-current`,
- detail status updates avoid inaccessible-only visual changes.
## Risks and tradeoffs
- Catalog previews are remote URLs; they can fail offline. The UI must remain usable with neutral/blank preview surfaces.
- This is a bigger Pet Manager UI rewrite than Phase 13A; keep install/state/security code unchanged to limit risk.
- Without real per-animation preview images, the Idle/Happy/Wave tiles will reuse available pet art rather than showing true animation states.
- Category filters are intentionally omitted until catalog metadata includes real tags.
## Security/privacy notes
- No new persisted data.
- No broad install behavior changes; narrow stale-target cleanup is allowed when a previously failed/manual install leaves files on disk while state says the pet is not installed.
- No direct use of `lfs/pets.png` at runtime.
- Keep Electron renderer sandbox/context isolation unchanged.
- CSP should allow only the image sources needed for data bundled assets and exact existing catalog preview origin (`https://openpets.dev`); no remote script/style execution and no `file:` images.
- Removing a removable current-default pet must preserve existing safe fallback behavior to the built-in pet.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
```bash
pnpm package:desktop:dir
```
## Manual verification guide
1. Run `pnpm dev:desktop`.
2. Open Pet Manager from the tray.
3. Confirm the page resembles `lfs/pets.png` direction: light two-column gallery/detail layout, not a literal screenshot paste.
4. Confirm the default/bundled pet is selected by default and shows a large detail panel.
5. Type in search and confirm gallery cards filter.
6. Click `All` and `Installed` and confirm results update gracefully.
7. Click a catalog pet and confirm the detail panel updates.
8. Install a catalog pet and confirm it becomes installed/selectable without breaking the UI.
9. Set an installed pet as default and confirm selected/default state updates.
10. Remove a non-protected installed pet and confirm it disappears/falls back safely.
11. If the current default is a removable installed pet, remove it and confirm OpenPets falls back safely to the built-in/default pet.
12. Simulate offline/catalog error or use fixture/error state if available; confirm the gallery remains usable and image failures show neutral preview surfaces with no broken image icon.
13. Confirm a broken installed pet, if present, is shown but cannot be selected as default.
14. Confirm an installed pet missing from the current catalog still appears and remains manageable.
15. Resize to 720×520 and confirm the layout remains usable with scrolling.
16. Quickly inspect onboarding, Settings, and Agent Setup for obvious shared-style regressions.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Clarify remote preview policy: only validated `CatalogPetV2.preview` from main-process catalog data; exact CSP such as `img-src data: https://openpets.dev`; no broad `https:`, `*`, or `file:`; no mutable `InstalledPetState.source.preview` without revalidation.
- Require previews to crop a single sprite frame, not show full spritesheets.
- Use DOM `addEventListener("error", ...)` for image failures, not inline `onerror` attributes.
- Add offline/image-failure behavior: no broken image icon, visible fallback thumbnail/card/detail, install/default/remove still usable.
- Preserve current-default removal semantics explicitly: removable current default can still be removed and safely falls back to built-in.
- Require `textContent`/DOM APIs for all catalog/state strings; no `innerHTML`.
- Make `pnpm package:desktop:dir` required if CSP/assets/packaging contract changes for gallery images.
Nice-to-have feedback:
- Use `loading="lazy"`, `decoding="async"`, and `referrerpolicy="no-referrer"` for remote preview images.
- Keep gallery model helpers small.
- Add manual checks for catalog offline/fixture/error, broken installed pet, and installed pet missing from current catalog.
- Add package contract checks for Pet Manager CSP and bundled logo/default thumbnail assets if used.
Verdict: not implementation-ready until image/CSP rules and spritesheet cropping/fallback behavior are tightened.
## Oracle feedback disposition
Fixed:
- Added exact remote preview policy and CSP constraints.
- Added single-frame spritesheet crop requirement.
- Replaced inline `onerror` language with DOM listener requirement.
- Added offline/image-failure fallback acceptance and manual checks.
- Added current-default removal fallback acceptance.
- Added DOM/textContent requirement.
- Made `pnpm package:desktop:dir` required for this phase.
- Added manual checks for broken/missing/catalog error states.
Accepted:
- Use lazy/async/no-referrer attributes for remote preview images.
- Keep gallery helpers small and avoid framework-like preload code.
- Add package contract checks for Pet Manager CSP and bundled assets when implemented.
## Implementation review
Reviewed by Designer and Oracle after implementation.
Validation passed:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
pnpm package:desktop:dir
```
Initial Designer should-fix feedback:
- Add keyboard-visible focus styling for Pet Manager cards.
- Adjust spritesheet crop/container sizing to avoid distorting the first-frame preview.
Initial Oracle should-fix feedback:
- Fix nested interactive keyboard behavior where card-level Enter/Space handling could intercept nested action button keyboard activation.
- Tighten packaging contract CSP assertions so onboarding and Pet Manager CSPs are checked separately and exactly.
Final Designer verdict: approved. Previous UI/accessibility feedback resolved.
Final Oracle verdict: approved for closeout. Previous security/CSP/data-integrity feedback resolved.
## Implementation feedback disposition
Fixed:
- Added `.pm-pet-card:focus-visible` styling.
- Adjusted thumbnail/mini sprite containers to match the 8×9 spritesheet frame aspect more closely.
- Guarded card keydown handling with `event.target !== card` so nested action buttons keep normal keyboard activation.
- Tightened package contract checks to assert exact onboarding and Pet Manager CSP strings.
- Strengthened catalog preview URL validation with `URL` parsing, exact protocol/host/no credentials/no port/path/`.webp` checks.
- Updated filter rendering so active visual state stays in sync and detail prefers visible filtered pets.
## Manual follow-up fixes
Fixed after manual testing:
- Pet Manager now opens at 1160×780 while other task windows keep their prior default sizing.
- Removed the large embedded default spritesheet from Pet Manager HTML to avoid Electron `ERR_INVALID_URL` from oversized data URLs.
- Replaced visible `<img>` sprite cropping with validated, preloaded CSS background sprites so previews do not show broken image icons.
- Removed decorative platform/fallback art from the detail spotlight; the selected pet sprite is the focus.
- Added install cleanup for stale target directories before renaming a validated temp install directory into place.
- Added Pet Manager `no-referrer` meta for CSS background preview requests.
Accepted:
- Idle/Happy/Wave mini tiles currently reuse the available first-frame crop; true per-state animation previews can wait for explicit animation metadata.

View file

@ -0,0 +1,327 @@
# Phase 13D: Agent Setup Polish
## Goal
Redesign the Agent Setup window into a polished **Integrations** hub that matches the new OpenPets visual direction while preserving all existing Claude MCP and hook behavior.
The first screen is a grid of integrations. Claude is the only functional integration in this phase; future integrations are visible but inert/disabled. Claude can be quickly installed when safe, or opened into a Claude detail/configuration view.
## Non-goals
- No changes to Claude MCP command semantics.
- No changes to Claude hook install/uninstall behavior beyond UI copy/styling.
- No new persisted state or settings schema.
- No hiding command/JSON previews; they remain available for trust and manual fallback.
- No remote assets or new network calls.
- No committing real Claude settings or user data in tests.
- No functional implementation for Cursor, OpenCode, VS Code, Windsurf, or Zed in this phase.
## User-visible/manual outcome
Opening **Configure Agents** shows a polished integrations grid:
- cards for Claude, Cursor, OpenCode, VS Code, Windsurf, and Zed using bundled icons,
- quick install/configure controls on the Claude card,
- disabled/coming-soon controls on future integration cards,
- a Claude detail/configuration view reachable from the Claude card,
- command/MCP JSON previews and enhanced Claude hooks controls in the Claude detail view,
- clear status badges and warning/result copy,
- polished buttons with proper icons and visual hierarchy,
- existing actions still work: refresh, copy command, configure, replace, remove, doctor hooks, install/update hooks, uninstall hooks.
## Acceptance criteria
- Agent Setup uses a polished light UI consistent with Pet Manager and onboarding.
- Layout is usable at the current task window size and responsive at 720×520 with scrolling.
- First view is an integrations grid, not a split Claude configuration view.
- Bundled integration SVG icons from `apps/desktop/assets/integrations/` are used through data URLs.
- Claude grid quick Install only runs existing safe `configure` when `canConfigure && !busy`.
- If Claude is not directly installable (already configured, not detected, mismatched entry, busy), the card action opens Claude details instead of mutating state.
- Hook install/update is never triggered from the grid quick action; hooks remain in Claude detail with visible global-setting warning.
- Non-Claude integration cards are disabled/inert and do not make IPC calls or persist state.
- Claude status states remain accurate: detected, not detected, configured, needs setup, error.
- Hook status states remain accurate: installed, needs update, not installed, error.
- Existing enable/disable rules are preserved:
- Configure only when `canConfigure` and not busy,
- Replace only when `canReplace` and not busy,
- Remove only when `canRemove` and not busy,
- Hook uninstall disabled when hooks are not installed,
- all relevant actions disabled while busy.
- Preview strings are inserted with DOM APIs/textContent, not `innerHTML`.
- Command and JSON previews remain selectable/copyable and never execute automatically.
- Warnings make replace/remove/global-hooks risk clear without overwhelming the page.
- Buttons use proper inline SVG icons, not ASCII symbols.
- Exact CSP/asset policy is explicit:
- if no images are used, keep the current restrictive CSP without `img-src`,
- if bundled logos/icons are used, allow only `img-src data:`,
- do not allow `https://openpets.dev`, broad `https:`, `file:`, `*`, remote fonts, or remote assets in Agent Setup.
- Replace, Remove, Hook Install/Update, and Hook Uninstall keep visible risk copy and require an explicit user click.
- The global Claude hooks warning remains visible near hook actions because hooks modify user-level Claude settings.
- Command-mode behavior is preserved: packaged builds remain forced to bundled mode by existing validation, and UI polish must not allow packaged users to switch to local/published mode or bypass mode validation.
- Inline SVG icons are created through static markup or DOM `createElementNS`; no `innerHTML` for icons or dynamic labels.
- Action result/status copy is exposed in a visible status region so busy/result changes are understandable.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- Designer review is completed for visual polish.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Replace Agent Setup markup with integrations hub plus Claude detail structure.
- Add scoped Agent Setup CSS under `body[data-openpets-view="agent-setup"]`.
- `apps/desktop/preload.cjs`
- Render status classes, mode controls, button icons, and preview copy into the new structure.
- Preserve existing IPC calls and action binding behavior.
- `docs/phases/phase-13d-agent-setup-polish.md`
- This spec, reviews, and dispositions.
## Technical approach
1. Keep `agent-setup.ts` business logic unchanged.
2. Build an integrations shell:
- `.integrations-view`: logo/title plus integration card grid,
- `.claude-detail-view`: Claude status, pet routing, command mode, MCP actions, previews, hooks, and back navigation.
3. In preload:
- map `status.state` and `hookStatus.status` to CSS classes,
- render action buttons with inline SVG icons,
- keep `textContent` for all dynamic status/details/previews,
- keep current button disable logic.
4. Make mode selection clear:
- Packaged/published command is visually primary/recommended in normal use,
- Local dev remains a checkbox/toggle only when available,
- Packaged mode copy explains `node` PATH requirement when relevant.
5. Make preview pane stable:
- fixed-height code panes with internal scroll,
- no layout jumps when preview text changes.
## Risks and tradeoffs
- This page touches trusted Claude configuration actions; visual polish must not reduce clarity about side effects.
- Hook settings are global for the Claude user, so the UI must keep that warning visible.
- Too much hiding of command/JSON preview would reduce trust; keep previews visible but visually secondary.
- Button icons improve scannability but labels remain required for clarity.
## Security/privacy notes
- No new execution paths.
- No new remote content.
- No `innerHTML`.
- If using the bundled logo, CSP is `img-src data:` only for Agent Setup.
- Existing output sanitization and path redaction remain in `agent-setup.ts`.
- Existing Claude settings backup behavior remains unchanged.
- Existing IPC sender allow-list remains unchanged.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
If Agent Setup CSP/assets/package contracts change, also run:
```bash
pnpm package:desktop:dir
```
If shared task-window styles are changed broadly, also run:
```bash
pnpm check
```
## Manual verification guide
1. Run `pnpm dev:desktop`.
2. Open Configure Agents from the tray.
3. Confirm the page looks consistent with Pet Manager polish.
4. Confirm Claude status, details, and available actions render clearly.
5. Toggle local dev mode if available and confirm previews update.
6. Change pet routing and confirm command/JSON previews update.
7. Copy command and confirm result text updates.
8. Run Refresh/Doctor and confirm status refreshes.
9. If safe on your machine, verify Configure/Replace/Remove still respect existing enable/disable states and confirmation intent.
10. Verify hook Doctor/Install/Uninstall buttons preserve current behavior and warnings remain visible.
11. Verify status variants where possible:
- Claude not detected,
- needs setup,
- configured,
- mismatched existing entry / Replace available,
- Remove available,
- hook installed,
- hook needs update,
- hook not installed,
- hook error.
12. For mutation tests, use safe/temp Claude settings or confirm backups are created before changing real user settings.
13. If packaged mode is available, confirm the UI does not expose local/published mode switching and uses bundled command behavior.
14. Resize to 720×520 and confirm the window remains usable with scrolling.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Specify exact Agent Setup CSP/asset policy: no images means current CSP; bundled logo means `img-src data:` only; no remote assets or copied Pet Manager preview CSP.
- Tighten trust UX for destructive/global actions: Replace, Remove, Hook Install/Update, and Hook Uninstall keep visible risk copy and require explicit click; hook global warning remains visible.
- Preserve command-mode behavior: packaged app remains forced to bundled mode, dev can toggle local/published only through existing validation.
- Inline SVG icons must not use `innerHTML`; use static markup or DOM `createElementNS`.
- Expand manual verification for status variants and safe/temp Claude settings.
Nice-to-have feedback:
- Consider wider Agent Setup sizing if two columns/previews feel cramped.
- Add `aria-live`/status styling criteria for action result and busy states.
- Run `pnpm package:desktop:dir` if CSP/assets/package contracts change.
Verdict: directionally sound after these clarifications; proceed with implementation after disposition.
## Oracle feedback disposition
Fixed:
- Added exact CSP/asset policy and disallowed remote Agent Setup assets.
- Added explicit trust UX criteria for Replace/Remove/hook actions and global hook warning placement.
- Added command-mode preservation criteria for packaged vs dev behavior.
- Added no-`innerHTML` icon requirement.
- Expanded manual verification matrix.
- Added package validation requirement when CSP/assets/package contracts change.
Accepted:
- Consider Agent Setup-specific sizing during implementation if 900×760 is cramped.
- Add visible status/result region and styling for busy/result changes.
## Integrations hub plan update
User direction changed during implementation: Agent Setup should become an integrations page/hub, not just a Claude split-view setup page.
Oracle reviewed the updated plan.
Blockers: none.
Should-fix feedback:
- Claude quick Install is narrowly defined: only existing non-destructive `configure` when `canConfigure && !busy`.
- If Claude is not directly installable, open details instead of mutating anything.
- Hook install/update remains only in Claude detail with visible global hook warning.
- Other integration cards are inert/disabled; no fake success or IPC calls.
- Agent Setup CSP remains `img-src data:` only; package contract should cover bundled SVG assets and no remote Agent Setup image sources.
- No `innerHTML`; use static shell/DOM APIs.
- Update docs for grid-first integrations hub and Claude-only functional scope.
Disposition: fixed in this updated spec and implementation plan.
## Implementation review
Reviewed by Designer and Oracle after implementation.
Validation passed:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
pnpm package:desktop:dir
```
Designer verdict: approved.
Designer should-fix feedback:
- Add accessible labels/live updates for command, MCP JSON, and hooks preview regions because they update when pet routing or command mode changes.
Oracle verdict: approved.
Oracle should-fix feedback: none.
Oracle nice-to-have feedback:
- Tighten the packaging contract Agent Setup CSP assertion to specifically check `createAgentSetupHtml`.
- Consider hiding/rewording disabled local dev mode in packaged builds later.
Integrations hub follow-up review:
- Designer verdict: approved. Nice-to-have: focus management and view transition polish.
- Oracle verdict: approved. Nice-to-have: after Claude quick Install, surface result in detail view; broaden package-contract remote-asset assertion.
## Implementation feedback disposition
Fixed:
- Added `aria-label` and `aria-live="polite"` to command, MCP JSON, and hooks preview regions.
- Tightened the package contract to assert Agent Setup's data-only CSP within `createAgentSetupHtml` specifically.
- Added grid → Claude detail → back focus management.
- Claude grid quick Install now opens Claude detail after configure so the action result is visible.
- Tightened package contract to reject any `https?:` reference inside `createAgentSetupHtml` and added bundled integration SVG safety checks.
Accepted:
- Packaged-build local dev mode copy can be refined later; existing command-mode validation and disabled state preserve safety.
- View transition polish can wait until the hub/detail interaction is manually reviewed.
## Claude detail redesign update
User feedback after manual review: the Claude detail page was too dense and confusing, and mismatched/custom `openpets` MCP entries should not appear as scary errors by default.
Updated UX direction:
- Claude detail is a single-column page with three clear areas:
- **Connection**: status, pet routing, and only the actions that apply now.
- **Optional Claude hooks**: hook status/actions with a global-settings warning.
- **Advanced details**: collapsed command/MCP JSON/hooks JSON previews for inspection/manual fallback.
- Existing/custom `openpets` MCP entries are treated as installed-but-custom/installed-but-unverified in the UI.
- `configure` still refuses to overwrite existing custom/unverified entries; `replace` remains explicit and separate.
- Remove and hook actions stay visible only in the Claude detail view, never from the grid quick action.
Review results:
- Designer approved the simplified single-column detail flow with no blockers or should-fix issues.
- Oracle approved safety/correctness with one should-fix: bundled-mode custom-entry warning must mention that Remove deletes the `openpets` MCP entry.
Disposition:
- Fixed bundled-mode warning copy to mention Remove risk.
- Calmed the backend `configure` refusal message for existing custom/unverified entries while preserving overwrite protection.
Validation passed after fixes:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
## Claude detail loading/action refinement
User feedback after manual review:
- Custom config repair should be a primary action labeled **Replace configuration**.
- MCP command/JSON preview belongs inside the Connection/MCP section.
- Hooks JSON preview belongs inside the Hooks section.
- Slow actions need immediate loading feedback; replace/remove/hook install/check/remove take several seconds.
Implemented:
- Renamed and promoted Replace to a primary **Replace configuration** button.
- Moved MCP advanced preview into the Connection card.
- Moved hooks advanced preview into the Hooks card.
- Added per-action loading labels/spinner and disabled surrounding controls while Claude actions run.
Review results:
- Designer approved with no blockers or should-fix issues.
- Oracle found two loading-state should-fix issues: copy command should not leave controls disabled, and failed actions must restore prior disabled states instead of blindly enabling everything.
Disposition:
- Copy command now bypasses the busy wrapper.
- Busy handling now stores/restores prior disabled states on failure.
Follow-up fixes:
- Back to integrations remains enabled while a Claude action is loading.
- Claude MCP status detection now allows a slower cold `claude mcp list` by using a longer timeout and retrying once after an initial timeout, so first open is less likely to show a transient timeout error that Refresh immediately fixes.

View file

@ -0,0 +1,188 @@
# Phase 13E: Onboarding Flow Polish
## Goal
Finish the first-run onboarding flow beyond the polished welcome screen so the Pets, Integrations, and Ready steps feel like a direct continuation of the welcome screen and guide users into the real product windows.
Phase 13B polished only the first welcome/greeting step. This phase polishes the remaining onboarding steps and makes the primary action on those steps open Pet Manager or Integrations.
## Non-goals
- No new pet installation behavior.
- No new Claude MCP or hooks behavior.
- No new persisted onboarding sub-step state.
- No remote assets or new network calls.
- No changes to runtime use of `lfs/greeting.png`; it remains reference-only.
- No committing real user data or Claude settings.
## User-visible/manual outcome
On first launch, onboarding should feel complete:
- Step 0 remains the polished OpenPets welcome.
- Step 1 explains choosing/managing pets and makes **Open Pet Manager** the primary action.
- Step 2 explains connecting coding agents and makes **Open Integrations** the primary action.
- Step 3 clearly explains that setup is done and where to reopen Pet Manager/Integrations later.
- The user can still skip/continue without installing a pet or configuring Claude.
- Pets/Integrations step copy makes focus behavior clear: if the user opens the product window, they should return to onboarding to continue.
## Acceptance criteria
- Onboarding still has four clear steps: Welcome, Pets, Integrations, Ready.
- Later steps use the same light polished visual language as the welcome/Pet Manager/Integrations work, not the older dark generic card style.
- Onboarding does not auto-open Pet Manager or Integrations on step entry.
- Open Pet Manager and Open Integrations are primary actions; Continue remains secondary beside them.
- If Pet Manager or Integrations is already open, clicking the primary action reuses/focuses the existing window through the existing `openTaskWindow` behavior and does not create duplicates.
- Manual open is best-effort: if a window open fails, onboarding shows a readable status/error and the primary button remains usable.
- Each open step has a dedicated status line with clear states: instruction text, `Opening…`, `Opened — return here to continue.`, and failure text.
- Manual buttons remain available:
- Open Pet Manager / Continue,
- Open Integrations / Continue,
- Start using OpenPets,
- optional Ready-step shortcuts.
- Step labels/copy/buttons use current product naming: “Integrations” rather than “Agent Setup” everywhere user-facing. Internal IPC/function names may remain unchanged.
- Onboarding completion behavior remains unchanged: Finish marks onboarding completed and closes the onboarding window.
- Closing onboarding before Finish still leaves onboarding incomplete and available later.
- Auto-opening Pet Manager/Integrations does not mutate pet catalog, Claude MCP config, or hook settings by itself.
- Existing IPC sender allow-list remains local-only and only allows onboarding to open internal task windows.
- Onboarding CSP remains `img-src data:` only; no remote assets.
- Dynamic text/status updates use DOM APIs/textContent, not `innerHTML`.
- Keyboard/tab access still reaches all actions.
- Window remains usable at 900×760 and 720×520 with scrolling if needed.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- Designer review is completed for the polished later-step UX.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Update onboarding markup and scoped styles for Pets, Integrations, Ready steps.
- Rename visible Agent Setup copy to Integrations where appropriate.
- `apps/desktop/preload.cjs`
- Trigger one-time Pet Manager/Integrations auto-open when entering steps 1 and 2.
- Keep manual buttons and completion behavior.
- `apps/desktop/src/check-packaging-contract.ts`
- Update assertions only if onboarding contract/copy/assets need coverage.
- `docs/phases/phase-13e-onboarding-flow-polish.md`
- This spec, reviews, and dispositions.
## Technical approach
1. Keep the existing four-step onboarding model.
2. Replace the older dark generic card markup for steps 13 with polished light cards:
- Pets: short explanation, “Pet Manager opened” status, Open/Continue actions.
- Integrations: short explanation, “Integrations opened” status, Open/Continue actions.
- Ready: clear final call-to-action and shortcuts.
3. In preload, keep `showStep(step)` focused on navigation only; do not auto-open child windows.
4. Clicking Open Pet Manager calls `onboardingApi.openPetManager()` and updates a dedicated status line.
5. Clicking Open Integrations calls `onboardingApi.openAgentSetup()` and updates a dedicated status line.
6. Continue buttons advance without opening windows.
7. Do not make open actions install/configure anything; they only open internal windows.
## Risks and tradeoffs
- Opening child windows can steal focus from onboarding. This now only happens after an explicit user click on the primary action.
- Onboarding step copy/status must explicitly say that the product window opened and the user should return to onboarding to continue.
- Pet Manager/Integrations are separate task windows, so users may need to return to onboarding manually after reviewing them. The onboarding copy should make that clear.
## Security/privacy notes
- No new external IPC or network behavior.
- Auto-open only calls existing internal window-opening IPC from the onboarding renderer.
- No automatic Claude configuration, hook installation, pet installation, file writes, or user settings mutation beyond final onboarding completion.
- Existing CSP restrictions remain.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
If onboarding CSP/assets/package contracts change, also run:
```bash
pnpm package:desktop:dir
```
## Manual verification guide
1. Run `pnpm dev:desktop`.
2. Reset onboarding state if needed by deleting the OpenPets app data directory.
3. Confirm Welcome still looks polished.
4. Click Next and confirm Pet Manager does not auto-open.
5. Confirm the Pets step visually matches the welcome screen direction and has Open Pet Manager as the primary action with Continue secondary beside it.
6. Click Open Pet Manager and confirm it opens/focuses Pet Manager and updates the status line.
7. Return to onboarding and click Continue; confirm Integrations does not auto-open.
8. Confirm the Integrations step visually matches the welcome screen direction and has Open Integrations as the primary action with Continue secondary beside it.
9. Click Open Integrations and confirm it opens/focuses Integrations and updates the status line.
10. Confirm neither auto-open action installs pets, configures Claude, installs hooks, or removes anything.
11. Confirm Ready step copy and shortcuts are clear.
12. Click Start using OpenPets and confirm onboarding closes and does not reopen on next launch.
13. Confirm closing before Finish keeps onboarding incomplete.
14. Resize to 720×520 and confirm all steps remain usable.
## Oracle plan review
Reviewed by Oracle.
Blockers: none.
Should-fix feedback:
- Clarify focus/window behavior because auto-open will likely steal focus from onboarding.
- Define once-only semantics precisely: entering steps repeatedly should not reopen/refocus unless the user clicks the manual button.
- Require dedicated status lines for `Opening…`, success, and failure.
- Rename visible Agent Setup copy to Integrations; internal names may stay.
- Confirm auto-open only happens after user advances, not on initial welcome render.
- Add manual verification for existing-window reuse/focus without duplicates.
Nice-to-have feedback:
- Add a return-to-onboarding hint in step copy.
- Add package-contract assertions if CSP/copy/assets change materially.
- Manually check small displays and multi-window behavior.
Verdict: implementation-ready after tightening focus/status/once-only criteria.
## Oracle feedback disposition
Fixed:
- Added focus-stealing/return-to-onboarding copy requirements.
- Defined once-only semantics per onboarding renderer session and manual button behavior.
- Added dedicated auto-open status line requirements.
- Required user-facing Integrations naming.
- Clarified no auto-open on initial welcome render.
- Expanded manual verification for reuse/focus/no duplicates.
Accepted:
- Package-contract assertions are only needed if CSP/assets/contracts materially change.
- Small display and multi-window behavior are included in manual verification.
## Revised UX direction
After an initial implementation pass, user feedback was that auto-open felt wrong and later steps did not visually match the first welcome page closely enough.
Updated direction:
- Do not auto-open Pet Manager or Integrations on step entry.
- Make **Open Pet Manager** / **Open Integrations** the primary action.
- Keep **Continue** as the secondary action next to the primary action.
- Ensure the later onboarding cards use the same light background, rounded glass card, clouds, typography, and visual direction as the welcome screen.
## Implementation notes
Implemented:
- Reworked Pets, Integrations, and Ready steps into polished light cards.
- Renamed user-facing Agent Setup copy/buttons to Integrations.
- Added dedicated auto-open status lines for Pet Manager and Integrations.
- Added one-time auto-open behavior for steps 1 and 2 after user navigation.
- Preserved manual open buttons and finish behavior.
Validation/reviews pending.

View file

@ -0,0 +1,207 @@
# Phase 13F: Settings and Tray Polish
## Goal
Polish the remaining basic desktop surfaces so Settings and tray/menu copy match the Phase 13 visual/product direction.
## Non-goals
- No new settings schema.
- No new pet behavior beyond existing preference/reset actions.
- No new Integrations/Claude behavior.
- No remote assets or network calls.
- No changes to onboarding flow beyond label consistency if needed.
## User-visible/manual outcome
- Settings opens as a polished light OpenPets window instead of the older dark generic card layout.
- Settings groups current controls into clear cards/rows with better copy and consistent button/toggle styling.
- Tray menu uses current product labels, especially **Integrations** instead of **Configure Agents**.
- Tray menu order is clearer and still exposes setup, pet visibility, pause/resume, Pet Manager, Integrations, Settings, and Quit.
## Acceptance criteria
- Settings uses a light background/card style consistent with Onboarding, Pet Manager, and Integrations.
- Existing Settings controls are preserved:
- Open default pet on app launch,
- Speech bubbles enabled,
- Pet scale coming later,
- Reset default pet position.
- Settings controls remain wired to the existing preload/state behavior.
- Settings result/error feedback remains visible.
- Settings remains usable at 900×760 and minimum 720×520.
- No remote assets are added; Settings CSP remains restrictive and does not need `img-src` unless images are added.
- Tray menu user-facing copy uses current naming:
- `Continue Setup...` can remain,
- `Manage Pets...` remains acceptable,
- `Configure Agents...` becomes `Integrations...`,
- Quit and pause/show/hide labels remain clear.
- Tray menu order remains:
- `OpenPets` disabled header,
- separator,
- conditional `Continue Setup...` plus separator when onboarding is incomplete,
- `Default Pet: Built-in Pet`,
- `Show/Hide Default Pet`,
- `Pause/Resume All Pets`,
- separator,
- `Manage Pets...`,
- `Integrations...`,
- `Settings...`,
- separator,
- `Quit OpenPets`.
- Tray actions still call the same internal functions/windows.
- Agent setup window title/user-facing definition uses **Integrations** (`OpenPets — Integrations`) while preserving internal `agent-setup` identifiers.
- Settings includes visible `aria-live` status feedback for successful preference saves and reset actions, plus existing error feedback.
- Settings-specific light styles are scoped to `body[data-openpets-view="settings"]`; avoid broad edits to global `button`, `.card`, or `.setting-row` behavior.
- Settings has clear keyboard/focus behavior: labels toggle checkboxes, reset button is reachable, focus states are visible.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
- Designer review is completed for Settings polish.
- Oracle implementation review is completed and feedback is dispositioned.
## Proposed files/directories
- `apps/desktop/src/windows.ts`
- Redesign Settings markup and scoped Settings CSS.
- `apps/desktop/preload.cjs`
- Adjust only if markup IDs/classes require it; preserve behavior.
- `apps/desktop/src/tray.ts`
- Update menu labels/order only.
- `docs/phases/phase-13f-settings-tray-polish.md`
- This spec, reviews, and dispositions.
## Technical approach
1. Keep `createSettingsHtml()` simple and data-free: no assets, no remote content.
2. Add scoped CSS under `body[data-openpets-view="settings"]` to avoid broad regressions.
3. Use native checkboxes/buttons with polished row/card styling; do not invent custom persistence.
4. Preserve existing element IDs used by preload:
- `open-default-pet-on-launch`,
- `speech-bubbles-enabled`,
- `pet-scale-value`,
- `reset-default-pet-position`.
5. Update tray label `Configure Agents...` to `Integrations...`; keep `openTaskWindow("agent-setup")` internally.
6. Update any visible `Configure Agents` window title/heading copy to Integrations while preserving internal task kind names.
## Risks and tradeoffs
- Settings shares global task-window CSS; keep new CSS scoped to Settings to avoid impacting other polished views.
- Tray label changes should not imply new functionality; use Integrations because that is now the actual window experience.
## Security/privacy notes
- No new IPC channels.
- No new external content.
- Existing preference update IPC sender allow-list remains Settings-only.
- Reset position remains the existing local state action.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
If shared styles unexpectedly affect multiple windows, also run/manual-check the app via:
```bash
pnpm dev:desktop
```
## Manual verification guide
1. Run `pnpm dev:desktop`.
2. Open Settings from the tray and confirm it matches the light OpenPets polish direction.
3. Toggle “Open default pet on app launch” and confirm it persists after closing/reopening Settings.
4. Toggle “Speech bubbles enabled” and confirm it persists after closing/reopening Settings.
5. Toggle each checkbox twice in the same open Settings window and confirm controls re-enable after each save.
6. Restart the desktop app and confirm both toggles persist; for “Open default pet on app launch,” confirm startup behavior matches the setting.
7. Show the default pet, move it, click Reset default pet position twice, and confirm it returns near the bottom-right of the primary display and the button re-enables after each click.
8. Confirm Settings status/success text updates after toggles and reset, and errors still display if something fails.
9. Resize Settings to 720×520 and confirm no clipped controls, usable scrolling, and visible status/error feedback.
10. Open tray menu and confirm `Integrations...` appears instead of `Configure Agents...` in the expected order.
11. Click every tray menu item once where safe: Continue Setup (if visible), Show/Hide, Pause/Resume, Manage Pets, Integrations, Settings.
12. Click `Integrations...` and confirm the Integrations window opens with the user-facing title/copy updated.
13. Smoke-check Pet Manager and Onboarding for obvious style regressions.
## Oracle plan review
Reviewed by Oracle.
Blockers:
- Settings preload ID inventory omitted `pet-scale-value`, which current preload requires.
Should-fix feedback:
- Define exact tray order.
- Update all user-facing “Configure Agents” surfaces, including agent window title/definition.
- Clarify Settings feedback behavior with visible `aria-live` success/status text or narrow to errors only.
- Add explicit minimum-size manual checks.
- Add restart persistence verification for settings toggles and startup behavior.
- Strengthen reset-position manual verification by moving/showing the pet first.
- Require Settings-specific scoped light overrides, not broad global style edits.
Nice-to-have feedback:
- Add keyboard/focus accessibility acceptance.
- Replace stale Settings definition copy.
- Smoke-test all tray menu items after reorder/rename.
Verdict: conditionally approved after spec fixes.
## Oracle feedback disposition
Fixed:
- Added `pet-scale-value` to required ID inventory.
- Added exact tray order.
- Required user-facing Integrations naming for tray and window title/definition.
- Required visible Settings status/success feedback.
- Added minimum-size, restart persistence, startup behavior, reset-position, and full tray smoke manual checks.
- Required Settings-specific scoped styling and keyboard/focus criteria.
Accepted:
- Settings definition copy will be updated as part of the user-facing Integrations/Settings polish.
## Implementation notes
Implemented:
- Redesigned Settings as scoped light UI with header, grouped panels, polished rows, focus states, and status feedback.
- Preserved existing Settings controls and IDs, including `pet-scale-value`.
- Added `settings-status` as an `aria-live` region for saving/reset feedback.
- Updated user-facing agent setup title/heading to Integrations while preserving internal `agent-setup` IDs.
- Renamed tray `Configure Agents...` to `Integrations...` without changing the underlying action.
Follow-up Settings changes:
- Removed the Speech bubbles toggle because speech bubbles are now always enabled.
- Added a Launch OpenPets at login toggle backed by Electron login item settings on supported platforms.
- Kept unsupported platforms disabled with explanatory copy.
Validation passed:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
Designer review: approved with no blockers or should-fix issues.
Oracle implementation review:
- Blocker: Settings controls were not re-enabled after successful saves/resets.
- Should-fix: failure status text could remain stuck on “Saving…”/“Resetting…”.
- Should-fix: manual verification should include repeated toggle/reset checks.
- Nice-to-have: add `role="status"` to Settings status.
Disposition:
- Fixed controls re-enabling in `renderSettings()`.
- Fixed failure status text for save/reset errors.
- Added repeated toggle/reset manual checks.
- Added `role="status"` to Settings status.

View file

@ -0,0 +1,168 @@
# Phase 14: Pet Scale Setting
## Goal
Make the existing `petScale` preference real: Settings should let users choose pet size, and the desktop pet should update without layout shift or cropping.
## Non-goals
- No new pet animation states.
- No new pet window transparency/drag behavior beyond preserving current behavior.
- No new persisted schema version.
- No custom per-pet scale; scale applies to the default desktop pet renderer.
- No changes to MCP speech safety or bubble content.
## User-visible/manual outcome
- Settings shows a usable Pet scale control instead of “coming later.”
- Changing scale updates the visible default pet shortly after save.
- The pet remains unclipped at supported scale values.
- Chat bubble remains an overlay above the pet and does not move the pet.
- Reset position still places the pet near the bottom-right using the window bounds.
## Acceptance criteria
- Settings exposes pet scale choices: Small, Medium, Large.
- Default scale is **Medium**, matching the current polished pet size after the bugfix.
- Persisted `preferences.petScale` accepts only supported numeric values and normalizes invalid/old values to the default.
- `validatePreferencePatch()` accepts only supported `petScale` values from Settings IPC; unsupported/non-finite values are rejected.
- Current and newly created state files use the new default scale.
- Changing scale via Settings saves through existing `openpets:update-preferences` IPC.
- Changing scale refreshes the default pet content immediately if it is visible.
- Unrelated preference updates, including launch/speech toggles and tray show/hide writes, do not reset the selected scale.
- Scale applies to built-in and installed default pet sprites.
- Scale is passed explicitly through the default-pet render path; explicit agent pet windows are not accidentally changed unless deliberately passed a scale later.
- Pet shell/card dimensions, sprite transform, and bubble offset all derive from the same scale constant/value.
- Bubble vertical position is derived from scale and stays close to the pet without shifting the pet.
- Pet and bubble do not get cropped at supported scales.
- Existing IDs needed by Settings/preload remain stable or are intentionally updated together.
- `pnpm --filter @open-pets/desktop build` passes.
- `pnpm --filter @open-pets/desktop test` passes.
## Proposed files/directories
- `apps/desktop/src/app-state.ts`
- Normalize/validate `petScale`.
- Change default scale to Medium.
- `apps/desktop/src/pet-window.ts`
- Use persisted `petScale` for sprite and bubble layout.
- `apps/desktop/src/windows.ts`
- Replace disabled “Pet scale” row with a real select/segmented control.
- Refresh pet content after scale updates.
- `apps/desktop/preload.cjs`
- Render and bind scale control.
- `docs/phases/phase-14-pet-scale-setting.md`
- This spec, reviews, and dispositions.
## Technical approach
1. Define supported scale values/default in a single pure source of truth, `app-state-core.ts`, and import/reuse where practical:
- Small: `0.44`,
- Medium: `0.56`,
- Large: `0.72`.
2. Make Medium (`0.56`) the default.
3. Preserve old/corrupt state safety by normalizing any unsupported `petScale` to Medium. Old persisted `petScale: 1` should normalize to Medium because `1` was never the actual post-bugfix visual render scale.
4. In Settings, add a `select id="pet-scale"` or equivalent simple control.
5. On scale change, call existing `updatePreferences({ petScale })` and show status feedback.
6. In main process preference update handler, compare previous vs next `petScale` and call `refreshDefaultPetContent()` only when scale actually changes.
7. In pet renderer creation, read `state.preferences.petScale` and pass/use it for:
- sprite transform scale,
- scaled shell size,
- installed card size,
- bubble bottom offset.
## Risks and tradeoffs
- Window size stays fixed at 220×260 for simplicity. Large scale is capped to fit within that viewport with the current bubble max height.
- Position persistence stores top-left window position, not pet visual anchor. Changing scale may visually change the pets bottom/right footprint but should not jump the window unexpectedly.
- Existing old state files with `petScale: 1` will normalize to Medium, preserving the current corrected default size while allowing users to choose a smaller Small option.
## Security/privacy notes
- No new IPC channels.
- Existing Settings-only preference update IPC is reused.
- Scale input is validated in the main process/state normalization.
- No remote assets or code execution changes.
## Test/check plan
Run:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
## Manual verification guide
1. Run `pnpm dev:desktop`.
2. Open Settings and confirm Pet scale offers Small, Medium, Large.
3. With the pet visible, switch between Small/Medium/Large and confirm the visible pet updates.
4. Change scale twice in the same Settings session and confirm controls stay enabled.
5. Toggle launch/speech settings and confirm the selected scale does not reset.
6. Test all scales with the built-in pet and with an installed pet set as default.
7. Trigger short and long speech bubbles/reactions at each scale, especially Large, and confirm bubble/pet are not cropped and the pet does not move.
8. Change scale while the pet is hidden, then show the pet and confirm the selected scale applies.
9. Confirm Reset default pet position still moves the pet near bottom-right at Small and Large.
10. Restart the app and confirm selected scale persists.
11. If manually editing state for verification, confirm fresh state defaults to Medium, old `petScale: 1` normalizes to Medium, and invalid values normalize to Medium.
## Oracle plan review
Reviewed by Oracle.
Blockers:
- Proposed Large `0.9` likely cannot meet no-cropping in the current fixed 220×260 window with max-height bubble.
- Settings IPC validation for `petScale` must be explicit because current `validatePreferencePatch()` only accepts launch/speech preferences.
Should-fix feedback:
- Define scale constants/default in one main-process source of truth.
- Clarify that old `petScale: 1` normalizes to the default because it was not the actual post-bugfix visual scale.
- Ensure unrelated preference updates do not reset scale.
- Pass scale explicitly through the default-pet path so explicit agent pet windows are not accidentally affected.
- Require derived dimensions/offsets to use the same scale.
- Refresh pet content only after real scale changes.
Manual verification gaps:
- Test built-in and installed default pets.
- Test long speech/reaction bubbles at each scale.
- Test fresh state, old `petScale: 1`, invalid values.
- Test repeated scale changes in one Settings session.
- Test scale while pet hidden, then show pet.
- Test reset position at Small and Large.
Verdict: conditionally approved after fixing scale sizing.
## Oracle feedback disposition
Fixed:
- Reduced Large to `0.84` to fit the fixed viewport with bubble.
- Added explicit Settings IPC validation requirement.
- Added one-source-of-truth scale constants requirement.
- Documented `petScale: 1` migration to the default.
- Added unrelated preference preservation, explicit default-pet scale path, derived dimension, and refresh-on-real-change criteria.
- Expanded manual verification matrix.
## Implementation notes
Implemented:
- Added supported scale constants in `app-state.ts`: Small `0.44`, Medium `0.56`, Large `0.72`.
- Moved scale constants/normalization into pure `app-state-core.ts` so lightweight tests can cover scale normalization without importing Electron.
- Changed default/invalid scale normalization to Medium.
- Added real Settings Pet scale select and status feedback.
- Added `petScale` validation to Settings preference IPC.
- Refreshes default pet content only when scale changes.
- Pet renderer derives shell size, sprite transform, installed-card size, and bubble offset from the selected scale.
- Explicit agent pet windows keep the Medium/default render scale for now; default pet windows use the saved preference.
Validation passed:
```bash
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```

View file

@ -0,0 +1,44 @@
# Phase 15 — Codex Pet Import
## Goal
Let users import locally generated Codex pets from `~/.codex/pets/` directly in Pet Manager.
## Implemented
- Added a **Codex** tab next to All and Installed in Pet Manager.
- Added main-process discovery for `~/.codex/pets/<pet-id>/pet.json` and `spritesheet.webp`.
- Codex pets are shown with local `data:image/webp` previews so the Pet Manager CSP does not need broader local-file image access.
- Local previews are only inlined when the raw spritesheet bytes are small enough and the Codex tab remains under a total raw preview-byte cap; oversized valid pets can still be imported but show an empty preview.
- Added Import action for Codex-only pets.
- If a pet exists in both the catalog and Codex, the Pet Manager uses Codex metadata/preview and Import uses the local Codex copy.
- Import copies only validated `pet.json` metadata and `spritesheet.webp` into OpenPets user data under the normal installed-pets directory.
- Imported Codex pets are recorded in app state with `source.kind = "codex"`.
- Missing `~/.codex/pets/` is treated as an empty Codex tab, not an error.
- Discovery scans at most 100 sorted Codex pet directories per render.
## Safety Rules
- Pet id must be safe and match the folder name.
- `spritesheetPath` must be exactly `spritesheet.webp`.
- `pet.json` and `spritesheet.webp` have size limits.
- The Codex root, source path, and pet files must stay inside `~/.codex/pets/`; root, pet directories, and files cannot be symlinks.
- Destination path must stay inside OpenPets pets storage.
- Import writes fresh regular files into OpenPets storage rather than copying links.
- Existing installed pets cannot be imported again.
- Import shares the same per-pet operation lock as catalog install/remove/default changes.
## Automated Checks
- `check-codex-pets.ts` covers Codex metadata validation and preview-size gating.
- Desktop test script includes the Codex check.
## Manual Verification
1. Ensure local pets exist under `~/.codex/pets/<id>/pet.json` and `spritesheet.webp`.
2. Open Pet Manager.
3. Confirm the Codex tab appears.
4. Confirm Codex pets show previews and descriptions.
5. Import a Codex pet.
6. Confirm it moves to Installed and can be set as default.
7. Confirm removing it deletes the OpenPets copy, not the original Codex folder.

View file

@ -0,0 +1,84 @@
# Phase 16 — Claude Hook Pet Routing
## Goal
When a Claude integration is configured with a selected pet, Claude hook speech/reactions should route to the same selected pet as the MCP server instead of always using the default desktop pet.
## Non-goals
- Per-project Claude hook installation. Current Claude hooks remain global user settings.
- Changing MCP lease behavior.
- Removing the default-pet fallback when no hook pet is configured.
## User-visible/manual outcome
- Installing/updating Claude hooks from Integrations with pet `fixer` writes hook commands containing `--pet fixer`.
- Prompt-submit hook speech appears on the fixer pet, matching the selected Claude integration pet.
- If no selected pet is configured for hooks, hook events continue targeting the default pet.
## Acceptance criteria
- `open-pets-claude hook --openpets-managed --pet <id>` validates `<id>` and passes it to hook handling.
- Hook handling acquires a short OpenPets lease for the configured pet and passes that `leaseId` into `say`/`react`.
- Integrations hook doctor/install uses the selected pet when checking/writing hooks.
- Hook status text makes the target explicit.
- Invalid hook `--pet` values are rejected at CLI boundary.
## Proposed files/directories
- `packages/claude/src/cli.ts`
- `packages/claude/src/hooks.ts`
- `packages/claude/src/hook-settings.ts`
- `packages/claude/src/check-claude-hooks.ts`
- `apps/desktop/src/agent-setup.ts`
## Technical approach
- Extend the Claude hook CLI with `--pet` parsing using the same pet-id validation as MCP setup.
- Include `--pet <id>` in generated Claude hook commands when a selected pet exists.
- During hook runtime, acquire a short lease for the configured pet and pass the returned lease id to the OpenPets client call.
- Do not release hook leases immediately; rely on the existing short TTL so the explicit pet remains visible briefly after the hook event.
- Preserve default-pet behavior when no `--pet` is present.
## Risks and tradeoffs
- Hooks are global in `~/.claude/settings.json`, so installing hooks for `fixer` makes hook events target `fixer` globally. Per-project hook routing is deferred.
- If the selected pet is missing or broken, local IPC falls back to default pet using existing lease fallback behavior.
## Security/privacy notes
- Hook `--pet` is validated against the safe pet-id pattern before use.
- Hook speech validation still rejects code, paths, URLs, and secret-like content.
- No hook payload content is logged by default.
## Test/check plan
- `pnpm --filter @open-pets/claude build`
- `pnpm --filter @open-pets/claude test`
- `pnpm --filter @open-pets/desktop build`
- `pnpm --filter @open-pets/desktop test`
- `pnpm --filter @open-pets/mcp test`
## Manual verification guide
1. Open Integrations.
2. Select `fixer` for Claude.
3. Update/install optional Claude hooks.
4. Confirm `~/.claude/settings.json` hook commands include `--pet fixer`.
5. Start Claude in a project using the `--pet fixer` MCP config.
6. Submit a prompt.
7. Confirm hook speech/reaction appears on the fixer pet, not the default pet.
## Oracle plan review
Skipped as emergency bugfix; implementation review requested immediately after the targeted fix.
## Oracle feedback disposition
Implementation review via Oracle found no blocking issues.
- Fixed: Added CLI-level regression coverage for `open-pets-claude hook --pet fixer` and invalid `--pet bad/pet`.
- Fixed: Hook status message now states whether hook events target the selected pet or default pet.
- Fixed: Hook CLI validates `--pet` at the boundary instead of relying only on IPC fallback.
- Fixed: Hook action journal commands include `--pet <id>` when applicable.
- Accepted: Hook leases are not immediately released; they rely on the existing 15s TTL to keep the explicit pet visible after hook events.

View file

@ -0,0 +1,298 @@
# Phase 17 — OpenPets CLI Project Setup
## Goal
Add a real npm-distributed OpenPets CLI that lets users configure the current project to use a selected pet with Claude, using one simple command.
Primary command:
```bash
npx @open-pets/cli configure --pet fixer
```
This should configure both Claude MCP and Claude hooks for the current project. No extra MCP/hooks questions.
## Non-goals
- App-installed `openpets` shim / Settings “Install CLI” button. Defer to a later phase.
- Shared/team project config by default.
- Supporting agents other than Claude.
- Remote/custom catalog flags.
- `openpets install <pet-id>`; defer pet installation CLI to a later phase.
- Exposing pet install/remove/default controls through MCP tools.
- Building a complex TUI. Keep interaction simple.
## User-visible/manual outcome
From a project directory, users can run:
```bash
npx @open-pets/cli configure --pet fixer
```
Expected result:
- Claude MCP is configured locally for the current project with OpenPets `--pet fixer`.
- Claude project-local hooks are configured with `--project-local --pet fixer`.
- Starting Claude from that project makes MCP tools and hook speech/reactions target fixer.
Users can also run:
```bash
npx @open-pets/cli configure --agent claude --pet fixer --cwd /path/to/project --yes
```
If `--pet` is omitted, the CLI lists installed pets and prompts for one.
## Acceptance criteria
- `packages/cli` exposes a real executable bin, `openpets`.
- `openpets configure --pet <id>` runs non-interactively for Claude using current working directory.
- `--agent claude` is accepted; unsupported agents fail clearly.
- `--cwd <path>` targets another project directory; default is `process.cwd()`.
- `--yes` is accepted as a compatibility/no-op flag; there is no final confirmation prompt in Phase 17.
- If `--pet` is missing, CLI queries installed pets from the running OpenPets desktop app and prompts the user to choose.
- CLI configures Claude MCP using local project scope, private to the current user.
- CLI configures Claude hooks in `<project>/.claude/settings.local.json`, private to the project/user.
- Claude MCP command is installed while spawning `claude` with `cwd` set to the resolved target project.
- Long-lived generated Claude MCP/hook commands use a stable package command, not a temporary `npx @open-pets/cli` install path.
- Hook commands include `--pet <id>`.
- Existing non-OpenPets hooks in `.claude/settings.local.json` are preserved.
- Existing OpenPets-managed hooks are replaced safely.
- CLI fails with clear instructions if OpenPets desktop app is not running.
- CLI fails clearly if Claude Code is unavailable on `PATH`.
- CLI validates selected pet ids and rejects unsafe values.
## Proposed files/directories
- `packages/cli/package.json`
- `packages/cli/src/index.ts`
- `packages/cli/src/check-cli-contract.ts`
- `packages/client/src/index.ts`
- `apps/desktop/src/local-ipc.ts`
- `apps/desktop/src/local-ipc-protocol.ts`
- `apps/desktop/src/check-local-ipc-protocol.ts`
- `packages/claude/src/hook-settings.ts` (reuse project-local hook writer by passing settings path)
## Technical approach
### CLI command shape
```bash
openpets configure [--agent claude] [--pet <id>] [--cwd <path>] [--yes]
```
Defaults:
- `--agent claude`
- `--cwd process.cwd()`
- configure both MCP and hooks
- local/private project config
### Installed pet discovery
Add a narrow local IPC/client method for the user-run CLI:
```ts
pets.list
```
Return only safe display data:
```ts
{
ok: true,
pets: [
{ id, displayName, installed: true, builtIn, broken }
],
defaultPetId
}
```
The CLI uses this to validate `--pet` and power the picker.
### Stable generated commands
Do not write temp/cache paths from `npx @open-pets/cli` into Claude config. Instead make the CLI package self-contained and expose wrapper subcommands:
```bash
openpets mcp --pet fixer
openpets hook --openpets-managed --project-local --pet fixer
```
Generated long-lived commands should use the package version being configured:
```bash
npx -y @open-pets/cli@0.0.0 mcp --pet fixer
npx -y @open-pets/cli@0.0.0 hook --openpets-managed --project-local --pet fixer
```
The `mcp` wrapper starts the existing OpenPets MCP server implementation. The `hook` wrapper delegates to the existing Claude hook handler.
This avoids storing an absolute path into npm's temporary `npx` cache.
### Claude MCP configuration
Use Claudes own CLI for local project MCP setup when available:
```bash
claude mcp add-json openpets '<json>' --scope local
```
JSON shape:
```json
{
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"],
"env": {}
}
```
Spawn `claude mcp add-json` with `cwd` set to the resolved target project directory so Claude's local MCP scope attaches to the intended project.
Do not directly mutate `~/.claude.json` in this phase; delegate that to `claude mcp add-json`.
### Claude project-local hooks
Write hooks directly to:
```text
<project>/.claude/settings.local.json
```
Use existing `installClaudeHooks(settingsPath, commandMode, selectedPetId)` with a CLI-appropriate command mode/path if possible. If needed, add a small command builder option so the npm CLI writes hook commands that call the installed npm Claude CLI:
```bash
npx -y @open-pets/cli@0.0.0 hook --openpets-managed --project-local --pet fixer
```
Project-local hooks are private and should not require committing repo files.
Before writing hooks:
- resolve and validate the target project directory;
- reject symlinked `<project>/.claude` directories;
- ensure `<project>/.claude/settings.local.json` resolves inside the target project;
- preserve existing non-OpenPets settings and hooks.
### Interactive picker
If `--pet` is missing:
- list usable installed non-broken pets, including built-in unless explicitly disallowed later
- use a simple stdin/stdout numbered picker
- no extra MCP/hooks questions
### Preflight and idempotency
Before writing anything:
- verify OpenPets desktop is reachable and supports `pets.list`;
- verify the selected pet is installed and usable;
- verify Claude Code is available on PATH;
- verify target project path and hook path safety;
- build the version-pinned wrapper commands.
If any preflight fails, do not write MCP or hooks.
If a local Claude MCP entry named `openpets` already exists, `configure` should replace it by invoking Claude's add/update flow for the same server name when supported by `claude mcp add-json`; if Claude refuses, fail clearly before writing hooks.
## Risks and tradeoffs
- Adding public local IPC pet listing is a new surface. Keep it read-only and minimal.
- Invoking `claude` CLI can fail if Claude Code is unavailable on PATH. CLI should print the exact failure and next step.
- Project-local hooks and Claude local MCP scope are stored in different places by Claude design.
- Npm `npx` hook commands may run package resolution at hook time. This is acceptable for npm-first CLI but may be slower than future app-installed shim.
- Generated commands are version-pinned for stability; users may need to rerun configure after upgrading OpenPets CLI.
## Security/privacy notes
- Do not expose filesystem install/remove/default mutation over MCP.
- Validate project path and pet id before writes.
- Only write inside `<project>/.claude/settings.local.json` for hooks.
- Reject unsafe project-local hook paths, including symlinked `.claude` directories.
- Preserve non-OpenPets settings/hooks.
- Do not log prompt/hook payloads or secrets.
## Test/check plan
- Unit/contract check for CLI arg parsing and generated Claude config.
- Contract check that existing hooks are preserved and OpenPets-managed hooks are replaced.
- Mocked `claude` binary check for exact `add-json` argv, `--scope local`, JSON shape, and spawned cwd.
- Noninteractive `--pet` check; missing `--pet` non-TTY failure/picker behavior.
- Generated command version pinning check.
- Project hook writer rejects symlinked `.claude` / unsafe cwd.
- IPC protocol check for `pets.list` validation/result shape.
- MCP public tool list remains exactly `openpets_status`, `openpets_say`, `openpets_react`.
- Package build/test:
```bash
pnpm --filter @open-pets/cli build
pnpm --filter @open-pets/cli test
pnpm --filter @open-pets/client test
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
pnpm --filter @open-pets/claude test
```
## Manual verification guide
1. Run OpenPets desktop.
2. In a test project, run:
```bash
npx @open-pets/cli configure --pet fixer
```
3. Confirm command succeeds without MCP/hooks prompts.
4. Confirm Claude local MCP entry for the project targets `--pet fixer`.
5. Confirm `<project>/.claude/settings.local.json` contains OpenPets hooks with `--pet fixer`.
6. Start Claude in that project.
7. Confirm `/mcp` shows OpenPets connected.
8. Submit a prompt and confirm hook bubble appears on fixer.
9. Call `openpets_status` and confirm actual target is fixer.
## Oracle plan review
Oracle reviewed the Phase 17 plan and recommended revision before implementation.
## Oracle feedback disposition
- Fixed: Changed long-lived generated commands from temporary absolute package paths to self-contained, version-pinned `npx -y @open-pets/cli@<version> mcp/hook ...` wrappers.
- Fixed: Removed final confirmation prompt from Phase 17; `--yes` is accepted as no-op compatibility only.
- Fixed: Spec now requires spawning `claude mcp add-json` with `cwd` set to the target project.
- Fixed: Added project-local hook path safety requirements for symlinked `.claude` and inside-project resolution.
- Fixed: Deferred `openpets install <pet-id>` out of Phase 17 to avoid expanding mutation/security scope.
- Fixed: Added preflight/idempotency requirements before writing MCP/hooks.
- Fixed: Added version pinning, mocked Claude CLI tests, project hook safety tests, noninteractive CLI tests, and MCP public tool-list regression to test plan.
## Implementation notes
- Added `openpets` bin in `@open-pets/cli`.
- Added `configure`, `mcp`, and `hook` CLI command paths.
- Added read-only `pets.list` local IPC/client method.
- `configure --pet <id>` writes config without requiring the desktop app to be running; omitting `--pet` still queries the running desktop app for interactive pet selection.
- Claude MCP config is written through `claude mcp add-json openpets <json> --scope local` with `cwd` set to the target project.
- Project-local hooks are prepared before MCP mutation and written only after MCP configuration succeeds.
- Generated MCP/hook commands use version-pinned `npx -y @open-pets/cli@<version>` wrappers.
- CLI-generated npm hook timeout is `10` seconds to tolerate `npx` startup.
- Project-local hooks include the internal `--project-local` marker so global OpenPets hooks can detect project-specific OpenPets hooks and skip themselves, preventing duplicate default/project pet reactions.
- `--force` / `--replace` removes any existing local Claude MCP `openpets` entry before adding the new one.
- Hidden maintainer flag `--local-dev` writes local `node <repo>/packages/cli/dist/index.js ...` commands for pre-release testing; it is intentionally omitted from user help.
## Oracle implementation review
Oracle approved the implementation after one revision pass.
- Fixed: Avoid partial config by parsing/merging hook settings before MCP mutation and writing hooks only after MCP succeeds.
- Fixed: Reject symlinked `.claude`, non-file `settings.local.json`, malformed hook event arrays, and unsafe project-local hook paths.
- Fixed: Added `publishConfig.access = public` for npm-distributed packages.
- Fixed: Added `pets.list` response shape validation.
- Fixed: Added mocked Claude CLI cwd/argv/JSON test.
- Fixed: Increased CLI-generated hook timeout and added MCP wrapper signal forwarding.
- Fixed: Added global-hook duplicate prevention using explicit project-local hook marker detection.
- Fixed: Hardened project-local hook detection to reject symlinks/non-files, cap settings size, and ensure the settings path stays under the Claude project dir.
- Fixed: Hook CLI boundaries reject `--pet` with a missing value.
- Accepted: Generated commands are pinned to the package version; release must publish `@open-pets/cli`, `@open-pets/client`, `@open-pets/claude`, and `@open-pets/mcp` at the same version.

Some files were not shown because too many files have changed in this diff Show more