Compare commits

..

1 commit

Author SHA1 Message Date
OpenPets Dev
8648cadc55 fix(desktop): OP2 flip speech bubble below pet when near top of screen 2026-06-13 22:59:26 +00:00
1767 changed files with 22789 additions and 674167 deletions

View file

@ -8,21 +8,21 @@ body:
value: |
Thanks for reporting a bug. Keep it short — we only need enough to reproduce it.
If the desktop app starts, please consider attaching your FamiliarOS log. It is not required, but it is highly recommended because it often explains startup, tray, familiar display, and integration issues.
If the desktop app starts, please consider attaching your OpenPets log. It is not required, but it is highly recommended because it often explains startup, tray, pet display, and integration issues.
To find it: open FamiliarOS from the tray/menu bar, choose **Open Logs Folder**, then attach `familiaros.log` to this issue. If FamiliarOS cannot start, check these folders manually:
- macOS: `~/Library/Application Support/FamiliarOS/logs/`
- Windows: `%APPDATA%\FamiliarOS\logs\`
- Linux: `~/.config/FamiliarOS/logs/`
To find it: open OpenPets from the tray/menu bar, choose **Open Logs Folder**, then attach `openpets.log` to this issue. If OpenPets cannot start, check these folders manually:
- macOS: `~/Library/Application Support/OpenPets/logs/`
- Windows: `%APPDATA%\OpenPets\logs\`
- Linux: `~/.config/OpenPets/logs/`
If the problem happened before the latest restart, `familiaros.previous.log` may also be useful.
If the problem happened before the latest restart, `openpets.previous.log` may also be useful.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Describe the bug. Include your OS/app version if you know it.
placeholder: "The desktop familiar disappeared after..."
placeholder: "The desktop pet disappeared after..."
validations:
required: true
@ -30,7 +30,7 @@ body:
id: logs
attributes:
label: Logs (optional, highly recommended)
description: Attach familiaros.log if you can. If the app cannot start or you cannot find it, leave this blank.
placeholder: "Drag familiaros.log here, or paste the most relevant lines."
description: Attach openpets.log if you can. If the app cannot start or you cannot find it, leave this blank.
placeholder: "Drag openpets.log here, or paste the most relevant lines."
validations:
required: false

View file

@ -1,5 +1,5 @@
name: Feature request
description: Suggest an improvement for FamiliarOS.
description: Suggest an improvement for OpenPets.
title: "Feature: "
labels: ["feature"]
body:
@ -13,7 +13,7 @@ body:
attributes:
label: What do you want?
description: Describe the feature or change you would like to see.
placeholder: "I want FamiliarOS to..."
placeholder: "I want OpenPets to..."
validations:
required: true

View file

@ -1,18 +1,18 @@
name: Submit a familiar
description: Suggest a familiar for the FamiliarOS gallery.
title: "Familiar submission: "
labels: ["familiar submission"]
name: Submit a pet
description: Suggest a pet for the OpenPets gallery.
title: "Pet submission: "
labels: ["pet submission"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a familiar. Keep it short — we only need the essentials.
Thanks for suggesting a pet. Keep it short — we only need the essentials.
- type: input
id: familiar-idea
id: pet-idea
attributes:
label: Familiar idea or name
description: What should the familiar be called, and what is it?
label: Pet idea or name
description: What should the pet be called, and what is it?
placeholder: "Pixel Penguin — a tiny penguin coding companion"
validations:
required: true

View file

@ -1,44 +0,0 @@
name: Deploy FamiliarOS Website
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy on VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -euo pipefail
cd /home/dev/src/FamiliarOS
# Ensure we are on the latest main branch
git fetch origin
git checkout main || git checkout -b main origin/main
git reset --hard origin/main
cd website
# Production build env
export VITE_SUPERTOKENS_API_DOMAIN="${{ vars.VITE_SUPERTOKENS_API_DOMAIN || '' }}"
export VITE_SUPERTOKENS_WEBSITE_DOMAIN="${{ vars.VITE_SUPERTOKENS_WEBSITE_DOMAIN || '' }}"
export VITE_SUPERTOKENS_API_BASE_PATH="${{ vars.VITE_SUPERTOKENS_API_BASE_PATH || '/auth' }}"
export VITE_SUPERTOKENS_WEBSITE_BASE_PATH="${{ vars.VITE_SUPERTOKENS_WEBSITE_BASE_PATH || '/auth' }}"
export VITE_AUTH_API_BASE_URL="${{ vars.VITE_AUTH_API_BASE_URL || '' }}"
export VITE_GITHUB_OAUTH_ENABLED="${{ vars.VITE_GITHUB_OAUTH_ENABLED || 'false' }}"
export VITE_AUTH_API_TIMEOUT_MS="${{ vars.VITE_AUTH_API_TIMEOUT_MS || '8000' }}"
export VITE_AUTH_HEALTH_TIMEOUT_MS="${{ vars.VITE_AUTH_HEALTH_TIMEOUT_MS || '6000' }}"
npm ci
npm run build
# Deploy via the existing VPS deploy script (requires passwordless sudo or manual password entry)
sudo /home/dev/familiaros-deploy/deploy-website.sh

3
.gitignore vendored
View file

@ -9,13 +9,10 @@ apps/desktop/dist-electron
*.tsbuildinfo
.DS_Store
local/
local-tools/
.slim/deepwork/
docs
!website/docs
*.diff
MERGE_NOTES.md
NEW_FILES.txt
PULL_REQUEST.md
README_OUR_CHANGES.md
.gitnexus

View file

@ -1 +0,0 @@
*

View file

@ -1,12 +0,0 @@
{
"timestamp": 1781761968.167321,
"quality_signal": 0.438779684727969,
"coupling_score": 0.35494021397105097,
"cycle_count": 3,
"god_file_count": 0,
"hotspot_count": 1,
"complex_fn_count": 143,
"max_depth": 17,
"total_import_edges": 1589,
"cross_module_edges": 824
}

View file

@ -1,78 +0,0 @@
[constraints]
no_god_files = true
[scan]
exclude = [
"node_modules",
"dist",
"dist/**",
"**/dist/**",
"build",
"build/**",
"**/build/**",
"coverage",
"coverage/**",
"**/coverage/**",
".test-dist",
".test-dist/**",
"**/.test-dist/**",
"dist-electron",
"dist-electron/**",
"dist-electron-win",
"dist-electron-win/**",
"website/dist",
"website/dist/**",
"website/coverage",
"website/coverage/**"
]
[[layers]]
name = "shared-packages"
paths = [
"packages/*/src/*"
]
order = 0
[[layers]]
name = "desktop-runtime"
paths = [
"apps/desktop/src/*",
"apps/desktop/contracts/*",
"apps/desktop/tests/*"
]
order = 1
[[layers]]
name = "plugin-runtime"
paths = [
"plugins/official/*/*"
]
order = 2
[[layers]]
name = "website-runtime"
paths = [
"website/src/*",
"website/server/*"
]
order = 3
[[boundaries]]
from = "website/src/*"
to = "apps/desktop/src/*"
reason = "The public site must not directly import Electron desktop runtime code. Shared logic belongs in packages or explicit API contracts."
[[boundaries]]
from = "website/src/*"
to = "plugins/official/*"
reason = "The public site must not depend on packaged plugin runtime internals."
[[boundaries]]
from = "packages/*/src/*"
to = "apps/desktop/src/*"
reason = "Reusable packages should remain host-agnostic and must not import desktop app internals."
[[boundaries]]
from = "plugins/official/*"
to = "apps/desktop/src/*"
reason = "Official plugins must bind through the plugin SDK and host contracts, not desktop private modules."

View file

@ -2,7 +2,7 @@
"metadata": {
"version": "1.0.0",
"last_run": "2026-05-24T09:29:48.346Z",
"root": "/Volumes/external/repos/familiars",
"root": "/Volumes/external/repos/pets",
"include_patterns": [
"apps/**/*.ts",
"packages/**/*.ts",
@ -32,21 +32,21 @@
"apps/desktop/contracts/local-ipc-protocol.contract.ts": "76d45670b202ebd96cbee64d1e8e53ec",
"apps/desktop/contracts/plugin-manifest.contract.ts": "2b0eaa64d1e02dce6b14cfee9c6d2b3d",
"apps/desktop/package.json": "b83883566725e5c9a97fc9d7b5fd189a",
"apps/desktop/src/agent-familiar-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-pet-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-setup.ts": "c2193dd06fa7686b4446ea7dc5686b92",
"apps/desktop/src/app-state-core.ts": "9f4ea019ddb620bf8ad70dfe83f27496",
"apps/desktop/src/app-state.ts": "89a68328e992076d114b90de5b8fbe1b",
"apps/desktop/src/assets.ts": "d59129388a45ae10b5319242fd7a67ee",
"apps/desktop/src/built-in-familiar.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/built-in-pet.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/catalog-validation.ts": "6bd6974357781c0f972ef2b2da76eb12",
"apps/desktop/src/catalog.ts": "37fe90b36fa2eee976c3617fe1ee5989",
"apps/desktop/src/check-cursor-desktop.ts": "80b0d5c1913a47143970447153762617",
"apps/desktop/src/check-opencode-desktop-setup.ts": "08b86952c137b2e917d296c0c4590e5c",
"apps/desktop/src/check-packaging-contract.ts": "c5f74134e8e5e98623e2c69e71a79eec",
"apps/desktop/src/claude-memory.ts": "37f0a29734fad5e2bb5bd97110e7ddfa",
"apps/desktop/src/codex-familiars-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-familiars.ts": "885f214e14db799953b1ccf13a264478",
"apps/desktop/src/default-familiar-controller.ts": "a46002d908de6596947cdaa1d122ea84",
"apps/desktop/src/codex-pets-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-pets.ts": "885f214e14db799953b1ccf13a264478",
"apps/desktop/src/default-pet-controller.ts": "a46002d908de6596947cdaa1d122ea84",
"apps/desktop/src/display.ts": "366ab73790c796771ee0df94affe0642",
"apps/desktop/src/lease-manager.ts": "a8a84622bbd73a3b680a15b709c8dff0",
"apps/desktop/src/lifecycle.ts": "a346d3ebe789e6d434858481af0757d0",
@ -55,9 +55,9 @@
"apps/desktop/src/local-ipc.ts": "a8f25fd0b47d560782f2332e30acc115",
"apps/desktop/src/logger.ts": "a92e8de9ede984afc378d2d29d5fce0e",
"apps/desktop/src/main.ts": "5678745b8ea5c311ada5259545bb694e",
"apps/desktop/src/familiar-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/familiar-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/familiar-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/pet-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/pet-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/pet-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/plugin-catalog-validation.ts": "507deea55077a1130f9d4623a8fe8342",
"apps/desktop/src/plugin-catalog.ts": "10cffa1505a621f2e4ec4d4e1379823c",
"apps/desktop/src/plugin-config.ts": "4f1f0b6a83d6f705c7ef7b98b2412677",
@ -66,7 +66,7 @@
"apps/desktop/src/plugin-manifest-reader.ts": "2ba5d272ed3ae1b7e0131d2efdaf4caf",
"apps/desktop/src/plugin-manifest.ts": "15fe4e34408053cebe8b1b42e84cc6e7",
"apps/desktop/src/plugin-package.ts": "2aa09526568e4fd4f961d883ef373a38",
"apps/desktop/src/plugin-familiar-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-pet-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-runtime.ts": "5065842b784ca91207bf8e6ce3d1fab5",
"apps/desktop/src/plugin-sdk-bridge.ts": "a925bdd85834f00474e058e2c711d097",
"apps/desktop/src/plugin-service.ts": "7a6ecfc1f62a35e5c6d440e876dac788",
@ -110,9 +110,9 @@
"packages/cursor/src/cursor-rules.ts": "a05f35efb92e61e2493ca122bcf42aab",
"packages/cursor/src/cursor-status.ts": "c0d0a4dc377998336cf034c82d5bf56a",
"packages/cursor/src/index.ts": "324347811550c7206820993f68651cba",
"packages/install-familiar/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-familiar/src/check-install-familiar.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-familiar/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/install-pet/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-pet/src/check-install-pet.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-pet/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/mcp/package.json": "220512b0569b05ea9ec17950df4eb429",
"packages/mcp/src/args.ts": "6b84e85ab049f35181294be5ad3efb7a",
"packages/mcp/src/check-mcp-contract.ts": "0029e9dc79f600482b4c3d1ff1a0e630",
@ -131,8 +131,8 @@
"packages/opencode/src/opencode-project-setup.ts": "84b378ea0b22f9a99eced35edd5e584b",
"packages/opencode/src/opencode-status.ts": "2640eed91327a35c57a7162ab6730ccc",
"packages/opencode/src/plugin.ts": "8265624c6f4d87274928dde47405d893",
"packages/familiar-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/familiar-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/pet-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/pet-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/pi/package.json": "e539f5df26ab1f7aabe73ef0f924ebb6",
"packages/pi/src/check-pi-compat.ts": "e2f02e876e051e7f302f9ccf835953e3",
"packages/pi/src/check-pi.ts": "47a4f7d28b07fffe130419975f1cc7a4",
@ -161,14 +161,14 @@
"packages/client/src": "a036a1f04099db816b2cd6b552fda034",
"packages/cursor": "bc481b08e9fbe5e0464e6aef98f8c2ef",
"packages/cursor/src": "e6ff31505d96d16dcbea0afcb680d2a3",
"packages/install-familiar": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-familiar/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/install-pet": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-pet/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/mcp": "786e8bc32d478dbaa34bc3d1831cc6ec",
"packages/mcp/src": "e89d476284c4ec2814c11a064fcb210b",
"packages/opencode": "dd2382941a7bfe672b2f27dcbfa51e45",
"packages/opencode/src": "ed024a6207b42efd076da44ab9587adb",
"packages/familiar-format": "ca85af1412e98214135e22bdcba5f406",
"packages/familiar-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/pet-format": "ca85af1412e98214135e22bdcba5f406",
"packages/pet-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/pi": "df51ca2497141871a94e3383d042f051",
"packages/pi/src": "2a70d56d1b7ce40ed8a0416141834dd4"
}

140
AGENTS.md
View file

@ -35,15 +35,15 @@ When plugin work is finished, update these docs if behavior, commands, manifests
## Logging for Fast DX
When working on desktop UI, renderer, IPC, catalog, plugin, or familiar-window behavior, add targeted logging as part of the implementation when it helps diagnose issues quickly.
When working on desktop UI, renderer, IPC, catalog, plugin, or pet-window behavior, add targeted logging as part of the implementation when it helps diagnose issues quickly.
Prefer concise, scoped logs that capture data shape, selected IDs, load/error states, and boundary decisions.
Route renderer diagnostics into the app log when possible so failures are visible in `familiaros.log`, not only DevTools.
Route renderer diagnostics into the app log when possible so failures are visible in `openpets.log`, not only DevTools.
Avoid noisy permanent logs, secrets, full payload dumps, or logging in tight animation/render loops.
## Control Center CSP
When adding any renderer-visible URL scheme, image source, dev server endpoint, or internal protocol, update the Control Center CSP in both `apps/desktop/vite.config.ts` and `apps/desktop/src/renderer/index.html`.
Common familiar image protocols include `familiaros-codex:`, `familiaros-installed:`, and `familiaros-familiar-preview:`; forgetting CSP causes images to load as the default/fallback familiar even when install/render logic is correct.
Common pet image protocols include `openpets-codex:`, `openpets-installed:`, and `openpets-pet-preview:`; forgetting CSP causes images to load as the default/fallback pet even when install/render logic is correct.
## Ubuntu VMware Testing
@ -51,138 +51,16 @@ An Ubuntu 24.04 ARM64 VMware/Vagrant development VM exists for Linux GUI testing
- VM directory: `/Volumes/external/vmware/ubuntu24`
- Provider: `vmware_desktop` / VMware Fusion on Apple Silicon
- Guest FamiliarOS checkout: `/home/vagrant/src/familiaros`
- Guest helper aliases: `cdpets` and `familiaros-dx`
- Guest OpenPets checkout: `/home/vagrant/src/openpets`
- Guest helper aliases: `cdpets` and `openpets-dx`
Do not mount the macOS FamiliarOS checkout into Ubuntu for development. The macOS `node_modules` tree contains platform-specific packages and ownership metadata; using it from Linux can break local macOS development. Ubuntu testing should use the isolated guest clone and its own Linux `node_modules`.
Do not mount the macOS OpenPets checkout into Ubuntu for development. The macOS `node_modules` tree contains platform-specific packages and ownership metadata; using it from Linux can break local macOS development. Ubuntu testing should use the isolated guest clone and its own Linux `node_modules`.
For Linux GUI bug reproduction or Electron desktop testing:
1. Start or inspect the VM from `/Volumes/external/vmware/ubuntu24` with `vagrant up` / `vagrant status`.
2. SSH with `vagrant ssh`.
3. In the guest, run `cdpets` then `familiaros-dx` to update dependencies, fix Electron sandbox permissions, and launch FamiliarOS in the Ubuntu desktop session.
4. Check guest logs at `~/.config/@familiaros/desktop/logs/familiaros.log`.
3. In the guest, run `cdpets` then `openpets-dx` to update dependencies, fix Electron sandbox permissions, and launch OpenPets in the Ubuntu desktop session.
4. Check guest logs at `~/.config/@open-pets/desktop/logs/openpets.log`.
The VM is configured to boot into the Ubuntu desktop (`graphical.target`) with GDM auto-login for the `vagrant` user. Prefer this VM when validating Linux-specific renderer, Electron, tray, familiar-window, IPC, plugin, or packaging behavior.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **FamiliarOS** (11102 symbols, 28390 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/FamiliarOS/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/FamiliarOS/context` | Codebase overview, check index freshness |
| `gitnexus://repo/FamiliarOS/clusters` | All functional areas |
| `gitnexus://repo/FamiliarOS/processes` | All execution flows |
| `gitnexus://repo/FamiliarOS/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
## Overnight Mode agentic continuation and safe self-batching
Canonical authorities:
- `/home/dev/src/FamiliarOS/docs/ops/FAMILIAROS_OVERNIGHT_MODE_AGENTIC_CONTINUATION_SELF_BATCHING_AND_GOAL_CREATION_DOCTRINE_2026-06-19.md`
- `/home/dev/src/FamiliarOS/docs/ops/FAMILIAROS_OVERNIGHT_MODE_CLOSEOUT_CONTINUATION_AND_BOUNDARY_SHAPE_2026-06-19.md`
Operational rule:
- the code word for this operating posture is `Overnight Mode`
- `Continuity Mode` and `Disciplined Agentic Mode` are accepted synonyms for the same behavior, but `Overnight Mode` remains the canonical repo term
- once `Overnight Mode` has been adopted for the repo workflow, it remains the default continuity posture unless the user gives newer explicit contrary direction; ordinary daytime interaction does not deactivate it
- stacked `continue` prompts must be interpreted as permission to keep draining the already-declared active lane or already-declared next adjacent slice, not as permission to reinterpret the roadmap from scratch
- future instances must continue to the natural close of the active lane rather than stopping at the first barely-acceptable packet boundary when one or two more adjacent sensible seams would materially complete the lane
- the correct bias is surplus-with-discipline: carry the extra adjacent seam when it is real, bounded, source-backed, and closure-improving; never invent fluff or decorative widening
- future instances must not drift sideways into a different donor family, different roadmap pillar, or unrelated code lane merely because the previous slice ended; this is the hard fresh-family boundary rule
- if the next family is not already present-tense authority-backed, stop at the boundary, state it explicitly, and do not let stacked `continue` prompts coerce a speculative opening
- if a real stop condition is active, stacked `continue` prompts do not override it; future instances must withstand and disobey those prompts until the blocker is actually resolved
- FamiliarOS-specific GitNexus and refactoring safeguards remain higher-specificity guards and are not weakened by `Overnight Mode`
The VM is configured to boot into the Ubuntu desktop (`graphical.target`) with GDM auto-login for the `vagrant` user. Prefer this VM when validating Linux-specific renderer, Electron, tray, pet-window, IPC, plugin, or packaging behavior.

View file

@ -1,12 +1,12 @@
# FamiliarOS Control Center: Design System & Engineering Manual
# OpenPets Control Center: Design System & Engineering Manual
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the FamiliarOS Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the OpenPets Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
---
## 1. Visual Direction & Heritage
The FamiliarOS Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop familiar managers with a modern, glassmorphic utility interface.
The OpenPets Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop pet managers with a modern, glassmorphic utility interface.
- **The Core Tension**: Playful nostalgia meets crisp, modern desktop productivity.
- **Atmospheric Depth**: Instead of flat, sterile surfaces, interfaces leverage rich color gradients, subtle mesh backdrops, structured panels, and tactile control elements.
@ -43,7 +43,7 @@ Surfaces are layered to establish clear spatial hierarchy and keep the interface
The desktop shell is designed to be self-contained, fitting exactly within the viewport height without scrolling the entire window.
- **Page Shell**: Max-width of `1160px` centered, utilizing a full-height flexbox column (`h-screen p-6`).
- **Hero Header**: A balanced layout placing title text and description on the left (`.hero-content`) and the wider brand artwork (`familiaros.webp`) on the right (`.hero-logo-container`). Keep artwork large enough to feel intentional, use `object-contain`, and avoid rounded wrappers or distortion.
- **Hero Header**: A balanced layout placing title text and description on the left (`.hero-content`) and the wider brand artwork (`openpets.webp`) on the right (`.hero-logo-container`). Keep artwork large enough to feel intentional, use `object-contain`, and avoid rounded wrappers or distortion.
- **Layout Split**: A clean grid split, typically:
- **Left Gallery/List Panel**: `1.25fr` — for browsing, searching, and filtering.
- **Right Detail/Action Panel**: `0.75fr` — for contextual inspection and heavy operations.
@ -61,7 +61,7 @@ The desktop shell is designed to be self-contained, fitting exactly within the v
- **Eyebrow / Section Header**: `text-xs font-bold uppercase tracking-[.18em] text-brand`
- **Card Title**: `text-sm font-semibold text-navy`
- **Metadata / Descriptions**: `text-xs text-slate-500`
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default familiar"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default pet"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
---
@ -78,14 +78,14 @@ All colors are mapped to functional roles to ensure consistency across themes.
| **Success / Ready** | `#059669` / `#34d399` | `emerald` | Active indicators, successfully installed badges |
| **Warning / Import** | `#d97706` / `#fbbf24` | `amber` | Codex source indicators, imports, attention states |
| **Featured** | `#7e22ce` / `#a855f7` | `purple` | Featured catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original FamiliarOS catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original OpenPets catalog filters and badges |
| **Danger / Remove** | `#dc2626` / `#ff6b6b` | `red` | Destructive actions, broken states, error messages |
---
## 6. Tactile Button System
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical familiar hardware and OS controls.
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical pet hardware and OS controls.
### Visual Architecture
- **Borders**: Darker semi-transparent bottom/side borders (`rgba(..., 0.32)`).
@ -130,12 +130,12 @@ Buttons are the core interactive elements. They reject flat modern trends in fav
- Amber/yellow-orange for `Originals`
- Match catalog v3/web gallery taxonomy where possible: `Featured` excludes originals, and category filters like `Western`/`Asian` exclude featured and originals.
### Familiar Cards & Badges
### Pet Cards & Badges
- Card grids need inner padding so selected rings and focus states are not clipped by the scroll container.
- Use badges to expose catalog identity, not just install state:
- `Original` for original/FamiliarOS familiars
- `Featured` for featured non-original catalog familiars
- `Western` / `Asian` for standard category familiars
- `Original` for original/OpenPets pets
- `Featured` for featured non-original catalog pets
- `Western` / `Asian` for standard category pets
- `Default`, `Installed`, and `Codex` for local state/source
- Avoid low-value detail badges such as `Available`; reserve detail badges for meaningful state or catalog identity.
@ -192,8 +192,8 @@ When refactoring other Control Center views, apply the following structural mapp
### B. Plugins Page
- **Old Layout**: Flat list of available integrations.
- **New Layout**: Grid-based layout matching the `.familiars-grid` structure.
- **Cards**: Use the `.familiar-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
- **New Layout**: Grid-based layout matching the `.pets-grid` structure.
- **Cards**: Use the `.pet-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
### C. Onboarding
- **Old Layout**: Full screen wizard.

View file

@ -1,11 +1,11 @@
# FamiliarOS Feature Inventory
# OpenPets Feature Inventory
This file is the current feature roll-up for this working fork of FamiliarOS.
This file is the current feature roll-up for this working fork of OpenPets.
It combines:
- the shipped baseline described in `README.md`
- the chat, memory, and prompt-window work from this implementation thread
- the MCP Toolkit Guide surface added in the desktop app
- the curated MCP toolkit surface added in the desktop app
> Looking for an isolated view of only our additions? See:
> - [`FEATURES_OUR_CHANGES.md`](FEATURES_OUR_CHANGES.md)
@ -15,34 +15,34 @@ It combines:
## Desktop Companion Core
- Tray-first desktop companion app
- Desktop familiar that can idle, react, wave, move, and speak
- Default familiar plus installed non-default familiars
- Per-agent familiar routing when integrations request a specific installed familiar
- Manual familiar dismissal with lease-aware recovery rules
- Native right-click familiar context menu
- Always-on-top familiar window with click-through background behavior
- Desktop pet that can idle, react, wave, move, and speak
- Default pet plus installed non-default pets
- Per-agent pet routing when integrations request a specific installed pet
- Manual pet dismissal with lease-aware recovery rules
- Native right-click pet context menu
- Always-on-top pet window with click-through background behavior
- Drag support with mouse passthrough recovery for Windows/Linux edge cases
- Adaptive familiar scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default familiar position
- Adaptive pet scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default pet position
- Launch at login support where the platform allows it
- Update checking against GitHub releases
- Open logs folder from the app
## Familiar Packs And Gallery
## Pet Packs And Gallery
- Built-in default familiar
- Installed familiar pack loading
- Local familiar import from ZIP
- Local familiar import from folder
- Codex familiar import flow
- Familiar catalog browsing
- Familiar preview thumbnails and spritesheets
- Default familiar selection
- Familiar gallery linkout
- Built-in default pet
- Installed pet pack loading
- Local pet import from ZIP
- Local pet import from folder
- Codex pet import flow
- Pet catalog browsing
- Pet preview thumbnails and spritesheets
- Default pet selection
- Pet gallery linkout
## Reactions And Speech
- Explicit familiar speech bubbles
- Explicit pet speech bubbles
- Reaction-only decorative bubble messages
- Reaction animation mapping
- User-configurable reaction-to-animation overrides
@ -51,42 +51,18 @@ It combines:
- Safer speech rules for agent-driven bubble content
- Bubble behavior that avoids showing code, logs, URLs, paths, or secrets in normal integration speech
## Familiar Text-to-Speech (Phase 2)
## Virtual Pet Care
- Settings > **Text-to-Speech** panel controls voice output for the familiar and floating chat.
- **System voice** uses the OS speech engine through the renderer (`window.speechSynthesis`) with optional voice-name matching and a 0.5×2.0× speed multiplier.
- **Cloud TTS providers**: OpenAI TTS, ElevenLabs, and an **OpenAI-compatible** preset for OpenRouter, LiteLLM, WaveSpeedAI, or a custom endpoint.
- **Local TTS provider**: Piper (spawned as a local process) for fully offline speech.
- Provider credentials are stored with Electron `safeStorage` and fall back to plain local storage when encryption is unavailable (`apps/desktop/src/tts-credentials.ts`).
- Per-provider model, voice, speed, endpoint preset, and custom endpoint controls.
- Dynamic voice list fetching for ElevenLabs; static voice lists for OpenAI / OpenAI-compatible.
- In-app **Test voice** and **Stop** buttons to preview the configured voice.
- Assistant replies and other speech are spoken through the familiar window renderer via a shared TTS service (`apps/desktop/src/tts-service.ts`).
- Respects quiet-hours: speech is skipped while quiet hours are active.
- Audio returned by cloud providers is validated (MP3/WAV magic bytes) and played through a renderer `<audio>` element.
- Endpoint validation enforces HTTPS for cloud providers, allows `http://localhost` for local proxies, and blocks private IP endpoints for cloud providers.
- Input capped at 2,000 characters; clear error messages when synthesis fails.
- Plugin SDK bridge: plugins granted the `voice:speak` permission can ask the familiar to speak text through the same TTS pipeline.
## Virtual Familiar Care
- Bundled `familiaros.virtual-familiar` plugin tracks hunger, energy, happiness, and affection
- Stats decay slowly while the app is running and recover when you feed, play, familiar, or nap
- Status values live in the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level); no on-familiar HUD overlay
- Single-click the familiar to familiar it (increases bond and happiness)
- Feed, play, and nap actions are also available from the familiar context menu
- Bundled `openpets.virtual-pet` plugin tracks hunger, energy, happiness, and affection
- Stats decay slowly while the app is running and recover when you feed, play, pet, or nap
- Status values live in the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level); no on-pet HUD overlay
- Single-click the pet to pet it (increases bond and happiness)
- Feed, play, and nap actions are also available from the pet context menu
- Automatic nudges when a stat gets low
## Custom Familiar Name
- Settings → General includes a **Familiar name** field for the default Familiar
- The name persists in `familiaros-state.json` and is applied through `validatePreferencePatch`
- The chosen name appears in the tray tooltip and the tray context menu
- The menu falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set
## Floating Chat Surface
- Double-click the familiar to open a floating prompt window
- Double-click the pet to open a floating prompt window
- Floating prompt window stays always on top
- Compact prompt-first mode at 96px height; expanded editor/history mode at 280px
- Prompt bar is sized for 23 lines of text
@ -110,9 +86,9 @@ It combines:
- Full conversation history stored and persisted to disk across app restarts
- History survives closing and reopening the floating chat window
- History stays in the prompt/editor surface rather than in the familiar bubble
- Familiar bubble only shows one assistant reply at a time
- Per-entry styling with role badges (You / Familiar / System / Error) and timestamps
- History stays in the prompt/editor surface rather than in the pet bubble
- Pet bubble only shows one assistant reply at a time
- Per-entry styling with role badges (You / Pet / System / Error) and timestamps
- **Multi-conversation support**: each conversation is a separate thread with its own messages
- **Conversation list** accessible from the History button in the floating chat window
- **Editor button** shows the current conversation's message list
@ -123,7 +99,7 @@ It combines:
## OpenAPI And BYOK Chat
- Main-process OpenAPI chat service
- User-entered prompt stays out of the familiar renderer
- User-entered prompt stays out of the pet renderer
- User-entered prompt stays out of logs unless logging is genuinely necessary elsewhere
- API key or token entry in Settings
- Secure storage with Electron `safeStorage` when available
@ -148,12 +124,12 @@ It combines:
- Custom local template
- Generic HTTPS template
## Familiar Character And Personality
## Pet Character And Personality
- System-prompt style familiar character field in General Settings
- System-prompt style pet character field in General Settings
- Character prompt persists in app state
- Character prompt is applied on future assistant replies
- Base instructions toggle — optionally include or exclude the default FamiliarOS behavior instructions in every chat
- Base instructions toggle — optionally include or exclude the default OpenPets behavior instructions in every chat
- Theme setting shared between control center and floating chat window
- Auto theme mode
- Light theme mode
@ -162,7 +138,7 @@ It combines:
## Memory System
- Local-first persistent FamiliarOS memory store
- Local-first persistent OpenPets memory store
- Main-process memory writer
- In-memory retrieval during chat
- On-disk persistence for durable memories
@ -175,47 +151,21 @@ It combines:
- Forget/delete support
- In-app memory viewer in Settings with search, edit, and delete
- **Chat history memory**: cross-conversation message search injects relevant past conversation excerpts into the system prompt
- Two separate databases: explicit memories (`familiaros-memory.json`) and chat history (`familiaros-chat-history.json`)
- Two separate databases: explicit memories (`openpets-memory.json`) and chat history (`openpets-chat-history.json`)
## Knowledge Store
- Local-first file and memory knowledge repository for the desktop app
- Store files from Settings or directly from floating-chat attachments
- File metadata index persisted in `familiaros-knowledge-store.json`
- Stored file contents copied to `userData/knowledge/files/` with sanitized names
- Automatic UTF-8 text extraction from text-like files (TXT, MD, JSON, code files, CSV, YAML, etc.)
- MIME type inference from file extension when not supplied
- File search by token and phrase overlap across file names and extracted text
- Relevance scoring with phrase bonus, recency weighting, and size penalty
- Relevant file context injected into OpenAPI chat system instructions
- Combined search across stored files and FamiliarOS memories
- Dedicated **Knowledge Store** tab in Settings with upload, search, list, and delete
- Store attached prompt-window files into the Knowledge Store via toolbar button
- File count cap (100 files) and per-file size cap (5 MB)
- Invalid-file rejection: empty files, non-Buffer data, missing names, and path-traversal names
- Electron-free core class (`knowledge-store-core.ts`) for deterministic Node unit tests
## FamiliarOS MCP Server (External-Agent Bridge)
This MCP server exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host. It is a small, controlled bridge: external agents can make your Familiar react, speak a safe bubble, or read/write memory, but they do not get unrestricted access to your desktop.
## OpenPets MCP Server
- Local IPC discovery file and per-run token flow
- MCP server for FamiliarOS desktop control
- Explicit target-familiar lease routing
- Default-familiar fallback when a requested familiar is unavailable
- `familiaros_status`
- `familiaros_react`
- `familiaros_say`
- `familiaros_memory_list`
- `familiaros_memory_search`
- `familiaros_memory_store`
- `familiaros_memory_forget`
- Central **FamiliarOS MCP Server** panel in Control Center → Integrations
- Command mode selector (published / bundled / local) in one place
- Node path override in the central panel
- Familiar routing selector in the central panel
- One-click **Test Server** health check
- One-click **Copy MCP JSON** for external hosts
- MCP server for OpenPets desktop control
- Explicit target-pet lease routing
- Default-pet fallback when a requested pet is unavailable
- `openpets_status`
- `openpets_react`
- `openpets_say`
- `openpets_memory_list`
- `openpets_memory_search`
- `openpets_memory_store`
- `openpets_memory_forget`
## Agent Integrations
@ -232,26 +182,21 @@ This MCP server exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CL
- OpenCode plugin support
- Cursor MCP config preview
- Cursor rules preview
- Familiar routing centralized in the FamiliarOS MCP Server panel
- Published package / bundled / local command modes centralized in the FamiliarOS MCP Server panel
- Agent-specific command path overrides (Claude command, OpenCode command)
- Node command path centralized in the FamiliarOS MCP Server panel
- Pet routing per supported integration
- Published package / bundled / local command modes for supported integrations
- Command path overrides for relevant integrations
## MCP Tool Servers (Built-in Chat Tools)
## Vanilla Chat MCP Tools
The MCP Tool Servers panel gives the built-in floating chat the ability to do real work on your local machine. It is separate from the external-agent bridge and works without any external agent installed.
- Top-level **MCP Tool Servers** panel in Control Center → Integrations
- Multi-select MCP tool activation for the built-in floating chat
- Works without Claude, Cursor, OpenCode, or Pi installed
- Multi-select MCP tool activation inside the floating chat window
- Tiered tool browser (Starter, Terminal & Systems, Advanced)
- Save & Activate / Deactivate All controls
- Tools run through an internal stdio MCP client in the main process
- Supported tools: filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite
## MCP Toolkit Guide Surface
## Curated MCP Toolkit Surface
The control-center Integrations page includes an **MCP Toolkit Guide** panel. It is not an automatic installer; it is a hand-picked reference that explains what popular MCP tools do, what permissions they need, and how to set them up manually or as a persistent bundle in supported hosts. Each entry includes copy-paste snippets and guidance so you can adopt the MCP ecosystem deliberately.
The control-center Integrations page now also includes a curated **MCP Toolkit** panel. This does not pretend to be a full unsafe one-click installer for the whole MCP ecosystem. Instead, it provides a practical, permission-aware reference surface with copyable snippets and docs links.
### Starter Stack In The Toolkit
@ -314,14 +259,14 @@ The control-center Integrations page includes an **MCP Toolkit Guide** panel. It
## Dashboard And Control Center
- Dashboard overview
- Familiar count
- Pet count
- Plugin health summary
- Update status summary
- Reaction mix charting
- Top companion activity
- Activity counters
- Settings view
- Familiars view
- Pets view
- Plugins view
- Integrations view
- Persistent route handling inside the control center window
@ -334,7 +279,7 @@ The control-center Integrations page includes an **MCP Toolkit Guide** panel. It
- Verbose dev logging toggle through environment
- IPC request logging
- Lease acquisition logging
- Familiar show lifecycle logging
- Pet show lifecycle logging
- Renderer console logging capture
## Packaging And Distribution
@ -343,15 +288,15 @@ The control-center Integrations page includes an **MCP Toolkit Guide** panel. It
- Windows packaging support
- Linux AppImage packaging support
- Additional Linux targets in config
- Packaged preload scripts for control center, familiar window, prompt window, and plugin surfaces
- Packaged preload scripts for control center, pet window, prompt window, and plugin surfaces
- Packaged official plugins as extra resources
- Packaging contract checks
## Safety And Boundary Rules
- Prompt text is kept out of the familiar renderer
- Prompt text is kept out of the pet renderer
- Automatic agent speech is decorative and best effort
- FamiliarOS speech bubbles are not meant to carry code, logs, URLs, file paths, or secrets
- OpenPets speech bubbles are not meant to carry code, logs, URLs, file paths, or secrets
- Tooling guidance in the MCP Toolkit emphasizes minimal permissions
- Docker MCP Toolkit is recommended as an isolation layer when the third-party MCP ecosystem feels too uneven
@ -359,6 +304,6 @@ The control-center Integrations page includes an **MCP Toolkit Guide** panel. It
This fork now gives you three layers at once:
- a playful desktop familiar
- a playful desktop pet
- a floating always-on-top chat surface with BYOK and memory
- a curated MCP/workbench surface for building a serious developer stack around the familiar instead of only driving it from a terminal
- a curated MCP/workbench surface for building a serious developer stack around the pet instead of only driving it from a terminal

View file

@ -1,10 +1,10 @@
# FamiliarOS — Our Changes (Isolated Feature List)
# OpenPets — Our Changes (Isolated Feature List)
This document lists only the features, UI surfaces, and capabilities added by our working fork. It is intentionally separate from the upstream `README.md` and `FEATURES.md` so the original repo maintainer can choose whether to adopt these changes wholesale, partially, or not at all.
## Floating Chat Surface
- Double-click the familiar to open a frameless, always-on-top floating prompt window.
- Double-click the pet to open a frameless, always-on-top floating prompt window.
- Compact prompt-first UX at a minimal 96px height; expanded editor/history mode at 280px.
- Prompt bar sized for 23 lines of text.
- Resizable with a corner grip; size is remembered per mode (compact/expanded).
@ -14,11 +14,11 @@ This document lists only the features, UI surfaces, and capabilities added by ou
## Chat History List UI
- Conversation history renders as a styled list of individual entries, not a raw textarea.
- Per-entry role badges: You, Familiar, System, Error.
- Per-entry role badges: You, Pet, System, Error.
- Color-coded entry backgrounds per role.
- Timestamps shown per entry.
- Scrollable with automatic scroll-to-bottom on new messages.
- Persists to disk across app restarts (`familiaros-chat-history.json`).
- Persists to disk across app restarts (`openpets-chat-history.json`).
## OpenAPI BYOK Chat
@ -29,35 +29,15 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- Built-in presets: OpenAI, OpenRouter, Azure template, LiteLLM, vLLM, Moonshot/Kimi, custom/local templates.
- Localhost-only allowance for plain `http`.
## Familiar Character & Base Instructions
## Pet Character & Base Instructions
- User-editable system-prompt style familiar character field in General Settings.
- User-editable system-prompt style pet character field in General Settings.
- Character prompt persists in app state and applies to future assistant replies.
- Base instructions toggle to include/exclude the default FamiliarOS behavior instructions in every chat.
## Custom Familiar Name
- Settings → General includes a user-editable **Familiar name** field for the default Familiar.
- The name persists in app state (`familiaros-state.json`) and is applied through `validatePreferencePatch`.
- The chosen name appears in the tray tooltip and tray context menu.
- Falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set.
## Familiar Text-to-Speech (Phase 2)
- Settings > **Text-to-Speech** panel for the familiar and floating chat.
- **System voice** via the OS speech engine with voice matching and 0.5×2.0× speed control.
- **Cloud providers**: OpenAI TTS, ElevenLabs, OpenAI-compatible (OpenRouter / LiteLLM / WaveSpeedAI / custom).
- **Local provider**: Piper for offline speech.
- Credentials stored with Electron `safeStorage` (plain fallback).
- Per-provider model, voice, speed, endpoint preset, and custom endpoint.
- Dynamic voice list fetching for ElevenLabs; static lists for OpenAI.
- Test voice + Stop buttons in Settings.
- Speech skips quiet hours, validates returned audio, and enforces HTTPS / localhost rules for endpoints.
- Plugin SDK `voice:speak` permission lets plugins speak through the same pipeline.
- Base instructions toggle to include/exclude the default OpenPets behavior instructions in every chat.
## Memory System
- Local-first durable FamiliarOS memory store.
- Local-first durable OpenPets memory store.
- Automatic memory capture from prompts (`remember that...`, `/remember`, `/memorize`, `/note`, `don't forget...`, `my name is...`, `my favorite...`, `I prefer...`, `I like/love/dislike/hate...`).
- Specific memory kinds (identity, preference) take precedence over generic notes when the prompt matches both.
- Relevance-based retrieval injected into future chat system prompts.
@ -66,58 +46,40 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- In-app memory viewer in Settings with search, inline edit, and delete with confirmation.
- Human-inspectable markdown mirror files on disk.
- **Chat history memory**: cross-conversation message search that injects relevant past conversation excerpts into the system prompt.
- Separate databases for explicit memories (`familiaros-memory.json`) and chat history (`familiaros-chat-history.json`).
- Separate databases for explicit memories (`openpets-memory.json`) and chat history (`openpets-chat-history.json`).
## MCP Tool Servers (Built-in Chat)
## Vanilla Chat MCP Tools
- Internal stdio MCP client in the desktop main process (`mcp-chat-client.ts`).
- Top-level **MCP Tool Servers** panel in Control Center → Integrations.
- Multi-select tool activation for the built-in floating chat, independent of external agents.
- Multi-select tool activation in the floating chat window.
- Tiered tool browser: Starter, Terminal & Systems, Advanced.
- Save & Activate / Deactivate All controls.
- ReAct-style tool-calling loop (max 5 iterations) with responses/chat-completions integration.
- Supported tools: filesystem, terminal/shell, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite.
## FamiliarOS MCP Server (External-Agent Bridge)
## Curated MCP Toolkit Integration
- Central **FamiliarOS MCP Server** panel in Control Center → Integrations.
- Command mode selector (published / bundled / local) in one place.
- Node path override in the central panel.
- Familiar routing selector in the central panel.
- One-click **Test Server** health check.
- One-click **Copy MCP JSON** for external hosts.
## MCP Toolkit Guide Integration
- MCP Toolkit Guide panel inside Control Center → Integrations.
- Hand-picked reference surface, not an automatic installer.
- Each entry explains what the tool does, what permissions it needs, and how to set it up manually or as a persistent bundle in supported hosts.
- Curated MCP Toolkit panel inside Control Center > Integrations.
- Multi-select chips by tier (Starter, Terminal & Systems, Advanced).
- Copy-paste snippets and docs links for deliberate adoption.
- Command Source labels clarify where tools come from:
- Stable release (npm)
- Shipped with this app
- Local source build
- Install Choice descriptions:
- Manual Setup = copy-paste commands
- Persistent Full Access = auto-register into Claude/Codex
- Removed redundant Curated Stack section in favor of direct tiered selection.
## Knowledge Store
## Pet Scale & Sizing
- Local-first file + memory knowledge repository added to the desktop app.
- Store files from Settings > Knowledge Store or directly from floating-chat attachments.
- Text extraction from UTF-8 text-like files; MIME inference from extension.
- Indexed metadata with file count cap (100) and per-file size cap (5 MB).
- Relevance search across file names and extracted text; results injected into OpenAPI chat instructions.
- Combined search across stored files and FamiliarOS memories.
- Dedicated Settings tab for upload, search, list, and delete.
- Toolbar button in the floating prompt window stores attachments into the Knowledge Store.
- Electron-free core (`knowledge-store-core.ts`) + unit tests (`knowledge-store.test.ts`).
## Familiar Scale & Sizing
- Settings slider for familiar scale from 0.16x up to 10x, large enough to fill the screen.
- Familiar window dynamically resizes to fit larger scales without clipping.
- Speech bubbles grow with the familiar and larger window sizes; long messages scroll instead of cutting off.
- Settings slider for pet scale from 0.16x up to 10x, large enough to fill the screen.
- Pet window dynamically resizes to fit larger scales without clipping.
- Speech bubbles grow with the pet and larger window sizes; long messages scroll instead of cutting off.
## Multi-Screen Support
- Familiar positioning uses `screen.getDisplayNearestPoint()` instead of always clamping to the primary display.
- `clampToNearestWorkArea()` keeps the familiar within the work area of the display it is currently on.
- Pet positioning uses `screen.getDisplayNearestPoint()` instead of always clamping to the primary display.
- `clampToNearestWorkArea()` keeps the pet within the work area of the display it is currently on.
## Multi-Conversation Chat
@ -127,22 +89,21 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- Create, switch, and delete conversations from the floating chat window.
- Editor button shows the current conversation's message list (unchanged).
- New Chat button creates a fresh conversation (old conversations are preserved).
- Conversation metadata stored in `familiaros-chat-conversations.json`.
- Conversation metadata stored in `openpets-chat-conversations.json`.
## Settings & Control Center
- Memory viewer section in Settings.
- MCP Tool Servers panel in Integrations for built-in chat tool activation.
- FamiliarOS MCP Server panel in Integrations for external-agent bridge configuration.
- Vanilla Chat tool activation section in Integrations.
- OpenAPI chat settings: endpoint, model, credential, theme, base instructions toggle.
- Moonshot/Kimi API endpoint preset.
## Virtual Familiar Status HUD
## Virtual Pet Status HUD
- Bundled `familiaros.virtual-familiar` plugin tracks hunger, energy, happiness, and affection.
- Status values are shown as read-only labels inside the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level), so they never block familiar clicks or double-click chat.
- Single-click the familiar to familiar it (increases bond and happiness).
- Feed / play / familiar / nap commands live in the familiar context menu.
- Bundled `openpets.virtual-pet` plugin tracks hunger, energy, happiness, and affection.
- Status values are shown as read-only labels inside the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level), so they never block pet clicks or double-click chat.
- Single-click the pet to pet it (increases bond and happiness).
- Feed / play / pet / nap commands live in the pet context menu.
- Low-stat nudges continue to work in the background.
## Build & Packaging

View file

@ -1,203 +0,0 @@
# FamiliarOS Feature Registry
This is the canonical feature registry for the FamiliarOS working repo. It maps every user-facing capability to the files, branches/PRs, and commits that implement it. It is kept in addition to `FEATURES.md` and `FEATURES_OUR_CHANGES.md` so that feature ownership, status, and history can be traced quickly.
- **Current branch:** `feat/familiaros-rebrand`
- **Current HEAD:** `69d3046`
- **Version:** `3.1.0`
- **Status key:** `shipped` = in the signed Windows installer; `merged` = in `v3.1.0-integrated`; `in-review` = has a PR branch; `experimental` = behind a flag or not yet packaged.
---
## 1. Companion / Familiar core
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Tray-first desktop companion | shipped | upstream | — | `apps/desktop/src/main.ts` | Baseline from upstream. |
| Default + installed familiars | shipped | upstream | — | `apps/desktop/src/familiar-installation.ts` | Baseline. |
| Always-on-top familiar window | shipped | upstream | — | `apps/desktop/src/familiar-window.ts` | Baseline. |
| Drag with mouse passthrough | shipped | `fix/familiar-window-stability` | `11ed7b3` | `apps/desktop/src/familiar-window.ts`, `apps/desktop/familiar-preload.cjs` | Removes post-drag resize jump; anchors resize around familiar foot/center. |
| Adaptive familiar scale 0.16x10x | shipped | `feat/scale-preset-dropdown` | `53b8435` | `apps/desktop/src/app-state-core.ts`, `apps/desktop/src/renderer/src/main.tsx` | Continuous slider + abbreviated preset dropdown. |
| Scale slider draggable thumb | shipped | `pr/scale-slider-draggable` | `2472950` | `apps/desktop/src/renderer/src/main.tsx` | CSS thumb styling so slider is grabbable. |
| Scale handle + keyboard nudges | **removed/replaced** | `fix/scale-handle-and-keyboard` | `be21907`, `26d446f` | `apps/desktop/src/familiar-window.ts` | Handle added, then removed in favor of Settings `+`/`-` nudge buttons. |
| Multi-screen positioning | shipped | main PR | `08eff41` | `apps/desktop/src/familiar-window.ts` | Uses `screen.getDisplayNearestPoint()` / `clampToNearestWorkArea()`. |
| Speech-bubble layout | shipped | main PR + OP2 | `08eff41`, `8648cad`, `f514960` | `apps/desktop/src/familiar-window.ts` | Larger bubbles, top-of-screen flip, responsive max-width for large familiars. |
| Single-click petting | shipped | `pr/single-click-petting` | `46028c7`, refined `be580ba` | `apps/desktop/familiar-preload.cjs`, `plugins/official/familiaros.virtual-familiar/index.js` | Single click emits `familiar:clicked`; double click opens chat. **Not a v3.1.0 follow-up fix; pre-existing feature refined by OP1.** |
| Virtual-familiar context menu | shipped | `pr/virtual-familiar-context-menu` + `refactor/virtual-familiar-context-menu` | `98b7875`, `ab62d9c`, `35e4d74` | `plugins/official/familiaros.virtual-familiar/index.js`, `plugins/official/familiaros.virtual-familiar/locales/en.json` | HUD moved from triple-click overlay to right-click context menu; care actions reordered/renamed. |
| FamiliarOS rebrand | **merged** | `feat/familiaros-rebrand` | current | repo-wide | App identity, package scopes, plugin IDs, state filenames, preload names, and user-facing strings updated from OpenPets to FamiliarOS. |
| Custom Familiar name | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/app-state.ts`, `apps/desktop/src/tray.ts`, `apps/desktop/src/renderer/src/main.tsx` | User-defined name for the default Familiar appears in tray tooltip and context menu. |
| Curated familiar catalog safety | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/catalog.ts`, `apps/desktop/src/catalog-validation.ts`, `apps/desktop/catalog.v2.fixture.json`, `apps/desktop/contracts/catalog-fixture.contract.ts` | Curated catalog metadata now preserves `original` / `featured` / category fields end-to-end, uses IP-safer FamiliarOS fixture familiars, and keeps surfaceable pagination totals aligned with the real searchable set. |
| Control Center bridge hardening | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/tests/plugin-ui-static.test.ts` | The Control Center API bridge now resolves to a required `ControlCenterApi` once at startup instead of leaking optionality through the whole renderer, and the static validation was updated to the stricter bridge shape. |
| Control Center route modularization | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/control-center/*.tsx`, `apps/desktop/src/check-packaging-contract.ts`, `apps/desktop/tests/plugin-ui-static.test.ts` | The renderer shell now routes into dedicated dashboard, familiars, settings, integrations, and plugins modules, with shared bridge/types/UI extracted into `control-center/shared.tsx` so future Control Center work no longer has to funnel through one giant file. |
## 2. Floating chat / prompt window
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Floating prompt window | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Frameless, always-on-top, compact/expanded modes. |
| Chat history list UI | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts` | Styled per-entry cards with role badges/timestamps. |
| Multi-conversation support | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/src/openapi-chat.ts` | Create/switch/delete conversations. |
| Attachments in prompt window | shipped | `op6/chat-file-attachments` + `fix/prompt-window-hardening` | `ccb3224`, `c226280` | `apps/desktop/src/prompt-window.ts` | File picker, attachment chips, base64 data-URL reading. |
| Auto-growing textarea | shipped | `fix/prompt-window-hardening` | `c226280` | `apps/desktop/src/prompt-window.ts` | Textarea fills resized window. |
| Enter sends / Ctrl+Enter newline | shipped | `fix/prompt-window-hardening` | `c226280` | `apps/desktop/src/prompt-window.ts` | Keyboard behavior. |
| Packaged-build inline-script newline fix | shipped | `fix/prompt-window` chain | `61da20d` | `apps/desktop/src/prompt-window.ts` | Escapes `\n` in generated inline script. |
| Store attachments in Knowledge Store | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Toolbar button stores each attachment into Knowledge Store. |
## 3. OpenAPI BYOK chat
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| OpenAPI chat service | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts` | Main-process chat with responses + chat/completions. |
| Provider presets | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/renderer/src/main.tsx` | OpenAI, OpenRouter, Azure, LiteLLM, vLLM, Moonshot/Kimi, custom. |
| Secure credential storage | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts` | `safeStorage` with plain fallback. |
| Familiar character / system prompt | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | User-editable character field. |
| Base-instructions toggle | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | Include/exclude default FamiliarOS behavior instructions. |
| Knowledge context injection | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/knowledge-store.ts` | Relevant stored files appended to system instructions. |
## 4. Memory system
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Local memory engine | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | JSON store + markdown mirror. |
| Memory capture patterns | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | `remember`, `my name is`, `I prefer`, etc. |
| Memory kinds + importance + tags | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | preference, identity, fact, note. |
| In-app memory viewer | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | Search, edit, delete. |
| Memory hardening | shipped | `pr/memory-hardening` | `2de484d` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/familiaros-memory.ts` | Capture logged + fails safely; recall-intent fallback. |
| Chat-history memory | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/familiaros-memory.ts` | Cross-conversation excerpts injected into prompt. |
## 5. Knowledge Store
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| File + memory knowledge repository | shipped | `pr/knowledge-store-hardening` | `4aff369`, `f514960` | `apps/desktop/src/knowledge-store-core.ts`, `apps/desktop/src/knowledge-store.ts` | Electron-free core + wrapper. |
| Text extraction + MIME inference | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | Text-like files only; non-text placeholder. |
| Relevance search + scoring | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | Token/phrase overlap, recency, size penalty; false-positive guard added. |
| Settings Knowledge Store tab | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/control-center-preload.cjs` | Upload, list, search, delete, add memory. |
| Prompt-window store button | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Store attachments from chat composer. |
| Unit tests | shipped | `pr/knowledge-store-hardening` | `4aff369`, `d8650a9` | `apps/desktop/tests/knowledge-store.test.ts` | Deterministic Node tests. |
| UTF-8 fallback | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | `TextDecoder` fallback when `Buffer.isUtf8` missing. |
## 6. MCP edition
### 6a. FamiliarOS MCP server
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| MCP server package | shipped | upstream / main PR | `3f8fa1a`+ | `packages/mcp/src/server.ts`, `packages/mcp/src/tools.ts` | `familiaros` MCP server. |
| Status / react / say tools | shipped | upstream | — | `packages/mcp/src/tools.ts` | Baseline. |
| Memory tools (list/search/store/forget) | shipped | upstream | — | `packages/mcp/src/tools.ts` | Baseline. |
| Lease routing + default-familiar fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
### 6b. MCP Tool Servers (built-in chat)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Internal stdio MCP client | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts` | Spawns tool servers inside floating chat. |
| Top-level "MCP Tool Servers" panel | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | Central place to activate tool servers for built-in chat, independent of external agents. |
| Tool activation UI | shipped | main PR | `08eff41` / current | `apps/desktop/src/renderer/src/main.tsx` | Multi-select chips by tier. |
| Supported tool servers | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts` | filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite. |
| ReAct-style tool loop | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts`, `apps/desktop/src/openapi-chat.ts` | Max 5 iterations. |
### 6c. FamiliarOS MCP Server (external-agent bridge)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Central "FamiliarOS MCP Server" panel | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/agent-setup.ts` | Configures command mode, node path, familiar routing, test server, and MCP JSON for external hosts. |
| Server health test | shipped | main PR | current | `apps/desktop/src/agent-setup.ts`, `apps/desktop/src/windows.ts` | `--version` smoke test via IPC. |
| MCP JSON copy | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | One-click copy of the `mcpServers.familiaros` entry. |
### 6d. MCP Toolkit Guide
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Toolkit panel in Integrations | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Curated, permission-aware reference surface. |
| Tiered family browser | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Starter / Terminal & Systems / Advanced. |
| Install orchestration | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-toolkit-installer.ts` | Persistent bundles for Claude/Codex baselines. |
| Command-source labels | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Stable release / shipped with app / local build. |
## 7. Agent integrations
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Claude Code integration | shipped | upstream / main PR | — | `packages/claude/` | Baseline. |
| OpenCode integration | shipped | upstream / main PR | — | `packages/opencode/` | Baseline. |
| Cursor integration | shipped | upstream / main PR | — | `packages/cursor/` | Baseline. |
| Pi guidance | shipped | upstream / main PR | — | `packages/pi/` | Baseline. |
## 8. Plugins / modding
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Plugin catalog / install / enable | shipped | upstream | — | `apps/desktop/src/plugin-*.ts` | Baseline. |
| Official plugin suite | shipped | upstream | — | `plugins/official/` | Baseline: focus-buddy, reminders, launch-buddy, water-reminder, mood-check-in, day-routine, magic-8-ball, fortune-cookie, virtual-familiar. |
| Virtual-familiar plugin bundling | shipped | `pr/virtual-familiar-context-menu` | `98b7875` | `plugins/official/familiaros.virtual-familiar/index.js` | Re-enabled after temporary disable. |
## 9. Text-to-Speech (Phase 2)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| TTS settings panel | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/renderer/src/main.tsx` | Provider, voice, speed, model, endpoint preset, credential, and test/stop UI. |
| System voice playback | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/familiar-preload.cjs`, `apps/desktop/prompt-window-preload.cjs` | OS speech engine via Web Speech API; rate 0.5×2.0× and voice-name matching. |
| Cloud TTS providers | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-engine.ts` | OpenAI TTS, ElevenLabs, and OpenAI-compatible endpoints. |
| Piper local TTS | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-engine.ts` | Spawns local `piper` binary; WAV magic-byte validation. |
| Credential storage | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-credentials.ts` | Per-provider API key stored with `safeStorage`; plain fallback. |
| Endpoint presets | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-catalog.ts` | OpenRouter, LiteLLM, WaveSpeedAI, Custom. |
| TTS service & quiet hours | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-service.ts` | Shared dispatcher; skips speech during quiet hours. |
| TTS IPC handlers | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/windows.ts` | `familiaros:get-tts-settings`, `familiaros:get-tts-voices`, `familiaros:save-tts-credential`, `familiaros:test-tts`, `familiaros:tts-stop`. |
| TTS engine tests | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/tests/tts-engine.test.ts` | Provider catalog, defaults, and preset validation. |
| Plugin voice bridge | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/plugin-voice.ts` | `voice:speak` permission lets plugins speak through the same pipeline. |
## 10. Packaging / release
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Electron Builder setup | shipped | upstream / `pr/packaging-and-docs` | `743163d` | `apps/desktop/electron-builder.yml` | Windows NSIS + portable, Linux AppImage/deb/rpm/tar.gz, macOS dmg/zip. |
| asarUnpack for preloads | shipped | `pr/packaging-and-docs` | `743163d` | `apps/desktop/electron-builder.yml` | Preload scripts unpacked for sandboxed packaged builds. |
| Packaging contract | shipped | `pr/packaging-and-docs` + `pr/knowledge-store-hardening` | `743163d`, `f514960` | `apps/desktop/src/check-packaging-contract.ts` | Assertions for preload presence, bubble CSS, etc. |
| Windows code signing | shipped | TTS Phase 2 / `v3.1.0-integrated` | `bb758e9` build | `apps/desktop/dist-electron/` | Signed with host certificate via `signtool.exe`. |
| Signed installer delivery | shipped | TTS Phase 2 / `v3.1.0-integrated` | `bb758e9` | `C:\familiaros-build\apps\desktop\dist-electron\FamiliarOS-3.1.0-win-x64-setup.exe` | SHA256: `79b0c01b08d6121f54675c176a5d7c22d1f8e3591b191d760aae6d6300d4b5bb`. |
---
## Removed / replaced capabilities
| Capability | What happened | Replacement | Commits |
|------------|---------------|-------------|---------|
| On-familiar drag-to-resize handle | Removed | Settings scale slider + `+`/`-` nudge buttons + keyboard arrows | `4aa60a3`, `be21907`, `26d446f` |
| Virtual-familiar triple-click HUD overlay | Removed | Right-click context menu **Familiar Status** | `4aa60a3`, `98b7875` |
| Triple-click familiar action | Removed | Single-click petting + double-click chat | `46028c7` |
---
## Documentation files
| File | Purpose |
|------|---------|
| `FEATURES.md` | Full feature inventory for the fork. |
| `FEATURES_OUR_CHANGES.md` | Isolated list of only our additions. |
| `FEATURE_REGISTRY.md` | This file — feature-to-branch/commit/files mapping. |
| `PULL_REQUEST.md` | Main PR description for the big feature set. |
| `README_OUR_CHANGES.md` | Isolated user-facing README of additions. |
| `docs/pull-requests/FAMILIAROS_3_1_0_FIXES_PULL_REQUESTS.md` | Categorized fix PRs for v3.1.0. |
| `docs/pull-requests/FAMILIAROS_3_1_0_MOD_NICHE_PULL_REQUESTS.md` | Same fixes organized by mod niche. |
| `docs/pull-requests/OP_1_6_PULL_REQUESTS.md` | OP1OP6 user-reported tweak branches. |
| `docs/pull-requests/PR_SINGLE_CLICK_PETTING.md` | Single-click petting PR (pre-v3.1.0). |
| `docs/pull-requests/PR_VIRTUAL_PET_CONTEXT_MENU.md` | Virtual-familiar context-menu PR (pre-v3.1.0). |
| `docs/pull-requests/PR_MEMORY_HARDENING.md` | Memory hardening PR. |
| `docs/pull-requests/PR_BUBBLE_CLIP_FIX.md` | Bubble clip fix PR. |
| `docs/pull-requests/PR_SCALE_SLIDER_DRAGGABLE.md` | Scale slider draggable thumb PR. |
| `docs/pull-requests/PR_PACKAGING_AND_DOCS.md` | Packaging/docs PR. |
| `docs/pull-requests/PROMPT_WINDOW_PACKAGED_BUILD_FIX_2026-06-15.md` | Prompt-window packaged-build fix root cause. |
| `docs/pull-requests/PR_TTS_PHASE_2.md` | Text-to-Speech Phase 2 PR description. |
| `docs/pr-chat-memory-mcp-toolkit.md` | Floating chat + memory + MCP toolkit explainer. |
---
## Branches on this repo
- `main` — upstream-aligned baseline.
- `v3.1.0-integrated` — integration branch with all v3.1.0 work plus TTS Phase 2 (HEAD `bb758e9`).
- `feat/scale-preset-dropdown`, `fix/familiar-window-stability`, `fix/prompt-window-hardening`, `fix/prompt-window-signed-build`, `fix/scale-handle-and-keyboard`, `refactor/virtual-familiar-context-menu` — topic branches from the six original PRs.
- `op1/familiar-drag-threshold``op6/chat-file-attachments` — user-reported tweak branches.
- `pr/bubble-clip-fix`, `pr/memory-hardening`, `pr/packaging-and-docs`, `pr/scale-slider-draggable`, `pr/single-click-petting`, `pr/virtual-familiar-context-menu` — PR branch pointers.
- `pr/knowledge-store-hardening` — Knowledge Store PR branch pointer (at `f514960`).

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 FamiliarOS
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

View file

@ -1,6 +1,6 @@
# Pull Request: FamiliarOS Rebrand, Custom Familiar Naming, Floating Chat, Memory, Knowledge Store, MCP Servers, MCP Toolkit Guide, TTS Phase 2 & Familiar Sizing
# Pull Request: Floating Chat, Memory, MCP Toolkit & Pet Sizing
**Title:** feat(desktop): rebrand to FamiliarOS, add custom Familiar naming, floating chat, local memory, Knowledge Store, MCP Tool Servers, FamiliarOS MCP Server panel, MCP Toolkit Guide, Text-to-Speech Phase 2, expanded familiar sizing, and multi-screen support
**Title:** feat(desktop): add floating chat, local memory, vanilla MCP tools, expanded pet sizing, and multi-screen support
**Author:** VectorShell working fork
**Base:** upstream `main` at `cba81b7`
@ -10,114 +10,73 @@
## Summary
This PR adds the FamiliarOS rebrand from OpenPets, a user-editable custom Familiar name, a complete in-app chat layer, a durable local memory system, interactive MCP tool activation, and several companion-quality improvements to the desktop familiar. Every change is additive and preserves existing MCP/agent integration behavior.
This PR adds a complete in-app chat layer, a durable local memory system, interactive MCP tool activation, and several companion-quality improvements to the desktop pet. Every change is additive and preserves existing MCP/agent integration behavior.
## Motivation
- Users wanted to chat with the familiar without a terminal or external agent.
- Users wanted to chat with the pet without a terminal or external agent.
- Long-running companions benefit from remembering facts, preferences, and identity.
- A curated but practical MCP surface helps users adopt tools without unsafe one-click installs.
- Familiar sizing was limited to three presets with no continuous or interactive adjustment.
- Pet sizing was limited to three presets with no continuous or interactive adjustment.
## What changed
### FamiliarOS rebrand
- Renamed the app, packages, plugin IDs, state files, preload files, and user-facing strings from OpenPets / pets to FamiliarOS / Familiars.
- Updated `README.md`, `FEATURES.md`, `README_OUR_CHANGES.md`, `FEATURES_OUR_CHANGES.md`, and `PULL_REQUEST.md` to reflect the new brand and voice.
### Custom Familiar name
- Added a **Familiar name** field in Settings → General.
- The name is persisted in `familiaros-state.json`, validated through `validatePreferencePatch`, and shown in the tray tooltip and context menu.
- Falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set.
### New surfaces
- **Floating Prompt Window** (`apps/desktop/src/prompt-window.ts`)
- Frameless, always-on-top chat UI opened by double-clicking the familiar.
- Frameless, always-on-top chat UI opened by double-clicking the pet.
- Compact mode at 96px height; expanded editor/history mode at 280px.
- Built as inline HTML/CSS/JS with a dedicated preload bridge.
- **Chat History List UI**
- Replaced plain-text history textarea with styled per-entry cards.
- Role badges (You / Familiar / System / Error), color coding, timestamps.
- Persists to disk (`familiaros-chat-history.json`).
- Role badges (You / Pet / System / Error), color coding, timestamps.
- Persists to disk (`openpets-chat-history.json`).
- **Memory Viewer**
- New Settings section to search, edit, and delete FamiliarOS memories.
- New Settings section to search, edit, and delete OpenPets memories.
- **MCP Tool Servers Panel**
- New top-level Integrations card to activate MCP tools for the built-in floating chat.
- Works without Claude, Cursor, OpenCode, or Pi installed.
- **Vanilla Chat MCP Tool Activation**
- New Integrations UI to activate MCP tools for use inside floating chat.
- Tiered chip selector (Starter / Terminal & Systems / Advanced).
- **FamiliarOS MCP Server Panel**
- New central Integrations card for the external-agent bridge.
- Command mode, node path, and familiar routing in one place.
- One-click server health test and MCP JSON copy.
- **Text-to-Speech (Phase 2)**
- Settings > **Text-to-Speech** panel with provider, voice, model, speed, endpoint, and credential controls.
- Providers: system OS voice, OpenAI TTS, ElevenLabs, Piper (local), and OpenAI-compatible (OpenRouter / LiteLLM / WaveSpeedAI / custom).
- Credentials stored with `safeStorage` (plain fallback) and tied to the selected provider.
- Shared TTS service (`tts-service.ts`) routes speech to the familiar / floating-chat renderer via `familiaros:tts-speak`, `familiaros:tts-audio`, and `familiaros:tts-stop` IPC.
- Cloud audio validated for MP3/WAV; endpoints validated for HTTPS / localhost rules.
- Skips speech during quiet hours.
### New core modules
- `apps/desktop/src/openapi-chat.ts` — OpenAPI BYOK chat with responses + chat/completions support.
- `apps/desktop/src/mcp-chat-client.ts` — Internal stdio MCP client for floating chat tools.
- `apps/desktop/src/familiaros-memory.ts` — Local memory engine with JSON store and markdown mirror.
- `apps/desktop/src/knowledge-store-core.ts` — Electron-free file + memory knowledge repository with search and context building.
- `apps/desktop/src/knowledge-store.ts` — Electron wrapper that wires the core to `app.getPath('userData')`.
- `apps/desktop/src/tts-service.ts` — Shared TTS dispatcher for familiar / floating chat with quiet-hours check.
- `apps/desktop/src/tts-engine.ts` — Provider-specific synthesis and voice fetching (OpenAI, ElevenLabs, Piper, OpenAI-compatible).
- `apps/desktop/src/tts-catalog.ts` — Provider catalog, defaults, and endpoint presets.
- `apps/desktop/src/tts-credentials.ts` — Secure credential storage for TTS providers.
- `apps/desktop/src/openpets-memory.ts` — Local memory engine with JSON store and markdown mirror.
- `apps/desktop/src/mcp-toolkit-installer.ts` — Toolkit install orchestration.
- `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` — Catalog data for the toolkit UI.
- `apps/desktop/prompt-window-preload.cjs` — Preload for the floating chat renderer.
### Familiar improvements
### Pet improvements
- Continuous familiar scale slider from 0.16x to 10x in Settings.
- Continuous pet scale slider from 0.16x to 10x in Settings.
- Dynamic window sizing so larger scales are never clipped.
- Multi-screen positioning (`clampToNearestWorkArea`).
- Removed the on-familiar drag-to-resize handle in favor of the Settings slider.
- Virtual-familiar status (`familiaros.virtual-familiar`) moved from an on-familiar triple-click HUD overlay to the right-click familiar context menu, so it never blocks chat clicks. Single-clicking the familiar triggers the `familiar()` care action.
- Removed the on-pet drag-to-resize handle in favor of the Settings slider.
- Virtual-pet status (`openpets.virtual-pet`) moved from an on-pet triple-click HUD overlay to the right-click pet context menu, so it never blocks chat clicks. Single-clicking the pet triggers the `pet()` care action.
### Settings additions
- OpenAPI chat: endpoint preset, model, credential, theme, base-instructions toggle.
- Moonshot/Kimi endpoint preset.
- Familiar character / system prompt field.
- **Familiar name** field for the default Familiar.
- Pet character / system prompt field.
- Memory viewer with search/edit/delete.
- **Knowledge Store** tab: upload files, search stored knowledge, delete files, and add manual memory entries.
### Bug fixes
- Memory capture/retrieval is now more robust in floating chat: capture is logged and errors are caught instead of breaking the send path.
- Speech bubble text no longer clips for long messages; bubble bodies scroll when content exceeds the bubble max-height.
- Fixed a packaged-build `SyntaxError` in the floating prompt window's inline script: newline literals in attachment separator strings are now correctly escaped so the script parses in the signed Windows installer.
- Verified the packaged prompt window with a Windows-hosted E2E smoke test over the reverse tunnel (CDP-driven: bridge present, input/send/attach elements render, typing enables send, no renderer exceptions).
### Knowledge Store integration
- Files stored in Settings > Knowledge Store are indexed and can be searched from the same panel.
- Stored files are automatically considered when building OpenAPI chat instructions: relevant files are appended to the system prompt.
- Attachments in the floating prompt window can be stored directly into the Knowledge Store via the toolbar button.
### Updated files
- `README.md` — added floating chat, memory, MCP Tool Servers, FamiliarOS MCP Server, and MCP Toolkit Guide bullets.
- `README.md` — added floating chat, memory, and vanilla chat tool bullets.
- `FEATURES.md` — comprehensive inventory of new capabilities.
- `docs/pr-chat-memory-mcp-toolkit.md` — implementation explainer.
- `docs/phases/phase-14-familiar-scale-setting.md` — expanded with new sizes and drag resize.
- `apps/desktop/src/familiar-window.ts` — removed scale handle; IPC handlers; dynamic window sizing.
- `apps/desktop/familiar-preload.cjs` — single/double-click detection; single-click emits `familiar:clicked` for virtual-familiar petting, double-click opens floating chat.
- `docs/phases/phase-14-pet-scale-setting.md` — expanded with new sizes and drag resize.
- `apps/desktop/src/pet-window.ts` — removed scale handle; IPC handlers; dynamic window sizing.
- `apps/desktop/pet-preload.cjs` — single/double-click detection; single-click emits `pet:clicked` for virtual-pet petting, double-click opens floating chat.
- `apps/desktop/src/app-state-core.ts` — expanded scale range.
- `apps/desktop/src/renderer/src/main.tsx` — Settings/Integrations UI additions.
- `apps/desktop/src/windows.ts` — memory IPC, toolkit IPC, preference handling.
@ -134,50 +93,35 @@ apps/desktop/prompt-window-preload.cjs
apps/desktop/src/mcp-chat-client.ts
apps/desktop/src/mcp-toolkit-installer.ts
apps/desktop/src/openapi-chat.ts
apps/desktop/src/familiaros-memory.ts
apps/desktop/src/openpets-memory.ts
apps/desktop/src/prompt-window.ts
apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
apps/desktop/src/knowledge-store-core.ts
apps/desktop/src/knowledge-store.ts
apps/desktop/tests/knowledge-store.test.ts
apps/desktop/src/tts-service.ts
apps/desktop/src/tts-engine.ts
apps/desktop/src/tts-catalog.ts
apps/desktop/src/tts-credentials.ts
apps/desktop/tests/tts-engine.test.ts
```
## How to test
```bash
pnpm install
pnpm --filter @familiaros/desktop build
pnpm --filter @familiaros/desktop test
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
```
All existing tests pass. Additional manual verification:
1. Launch the desktop app and double-click the familiar — floating chat should open.
1. Launch the desktop app and double-click the pet — floating chat should open.
2. Add an OpenAI-compatible API key in Settings > OpenAPI Chat.
3. Send a message; assistant reply should appear in the familiar bubble and in the history list.
3. Send a message; assistant reply should appear in the pet bubble and in the history list.
4. Say "remember that my favorite color is blue" — memory should be captured.
5. Open Settings > Memory — the memory should be searchable, editable, and deletable.
6. Open Integrations > MCP Tool Servers — activate Filesystem + Fetch/Web, then chat "list my home directory files" — tools should run.
7. Open Integrations > FamiliarOS MCP Server — choose a command mode, click Test Server, and verify the server responds.
8. Open Integrations > MCP Toolkit Guide to browse copy-paste snippets and optional persistent bundles.
9. Open Settings > Text-to-Speech, choose System voice, click **Speak** — the familiar should speak the test phrase.
10. Add an OpenAI TTS key, select a voice, click **Speak** — audio should play through the familiar window.
11. Store a `.txt` file in Settings > Knowledge Store and ask the assistant about its content — the reply should reference the stored file.
12. Right-click the familiar → choose Virtual Familiar ▸ Feed/Play/Familiar/Nap; stats update and the context menu reflects the new values.
13. Change the **Familiar name** in Settings → General; the tray tooltip and tray menu should immediately show the new name.
14. Change display — familiar should stay on the display it is currently near.
6. Open Integrations > MCP Toolkit — activate Filesystem + Fetch/Web, then chat "list my home directory files" — tools should run.
7. Right-click the pet → choose Virtual Pet ▸ Feed/Play/Pet/Nap; stats update and the context menu reflects the new values.
8. Change display — pet should stay on the display it is currently near.
## Backwards compatibility
- Existing MCP server behavior is unchanged.
- Existing agent integrations (Claude, OpenCode, Cursor, Pi) are unchanged.
- Existing plugin system is unchanged.
- TTS defaults to the system voice; no API key is required until a cloud provider is chosen.
- Preferences default safely: users without API keys see a clear "Add credential" state.
## Notes for upstream
@ -189,5 +133,5 @@ All existing tests pass. Additional manual verification:
1. Floating chat + OpenAPI BYOK
2. Memory system + memory viewer
3. Vanilla chat MCP tools
4. Familiar sizing expansion + drag handle
4. Pet sizing expansion + drag handle
5. Multi-screen positioning

423
README.md
View file

@ -1,25 +1,21 @@
<p align="center">
<img src="assets/familiaros.png" alt="FamiliarOS" width="100%" />
<img src="assets/openpets.png" alt="OpenPets - pixel art desktop companion" width="100%" />
</p>
<p align="center">
<strong>Create your Familiar.</strong>
<strong>A tiny desktop companion that keeps you company.</strong>
</p>
<p align="center">
Your Familiar remembers, speaks, and acts across your system.
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
</p>
<p align="center">
<sub>A local-first AI companion with memory, voice, knowledge, and tools.</sub>
<sub>This branch also includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>vanilla MCP tools</strong>, and <strong>expanded pet sizing</strong>. See <code>FEATURES.md</code> and <code>PULL_REQUEST.md</code> for details.</sub>
</p>
<p align="center">
<sub>This fork includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>Knowledge Store</strong>, <strong>MCP Tool Servers</strong>, a <strong>FamiliarOS MCP Server</strong> panel, an <strong>MCP Toolkit Guide</strong>, <strong>Text-to-Speech</strong>, <strong>Familiar naming</strong>, and <strong>expanded familiar sizing</strong>. See <code>FEATURES.md</code>, <code>FEATURE_REGISTRY.md</code>, and <code>PULL_REQUEST.md</code> for details.</sub>
</p>
<p align="center">
<img src="assets/intro.png" alt="FamiliarOS reacting across multiple coding agent sessions" width="100%" />
<img src="assets/intro.png" alt="OpenPets reacting across multiple coding agent sessions" width="100%" />
</p>
<div align="center">
@ -31,174 +27,381 @@
</p>
</div>
---
## What makes FamiliarOS different
FamiliarOS is a **local-first AI companion platform**.
Create your own Familiar: a persistent desktop companion with memory, voice, knowledge, and powerful agent capabilities.
> **Not just an assistant. A Familiar.**
Your Familiar can:
- 🧠 **Remember** preferences, facts, notes, identities, and important details across sessions.
- 🗣 **Speak and listen** with system voices, OpenAI, ElevenLabs, Piper, or OpenAI-compatible TTS providers.
- 📚 **Learn from your files** through the searchable Knowledge Store.
- 🔧 **Use MCP tools** such as filesystem access, terminal commands, web retrieval, Git, GitHub, Docker, Playwright, SQLite, memory systems, and reasoning tools.
- 💬 **Chat** through an always-on-top floating window.
- 🐾 **Interact** as a virtual companion with reactions, animations, and care actions.
Part virtual companion. Part AI assistant. Part autonomous agent. Entirely yours.
> **The best AI isn't just a tool you open. It's a companion that stays.**
---
## 2-minute Quick Start
Download and launch the desktop app from [FamiliarOS Releases](https://github.com/verticaltension/familiaros/releases/latest). A companion Familiar appears immediately; bundled abilities make it feel alive without requiring an agent setup.
Download and launch the desktop app from [OpenPets Releases](https://github.com/alvinunreal/openpets/releases/latest). A companion pet appears immediately; bundled abilities make it feel alive without requiring an agent setup.
If you also want coding-agent integration, install the FamiliarOS Agent Skill with [skills.sh](https://skills.sh/):
If you also want coding-agent integration, install the OpenPets Agent Skill with [skills.sh](https://skills.sh/):
```bash
npx skills add verticaltension/familiaros --skill familiaros
npx skills add alvinunreal/openpets --skill openpets
```
Then open Claude Code, OpenCode, Codex, or another skill-aware agent and say:
```text
Use the FamiliarOS skill. Install FamiliarOS for me, connect this agent, and verify the integration works.
Use the OpenPets skill. Install OpenPets for me, connect this agent, and verify the integration works.
```
For project setup, open your agent inside the repo and say:
```text
Use the FamiliarOS skill. Help me choose or install a Familiar, configure it for this project, and verify the project integration.
Use the OpenPets skill. Help me choose or install a pet, configure it for this project, and verify the project integration.
```
Useful prompts:
```text
Use the FamiliarOS skill. Configure this project for Claude Code with a Familiar.
Use the FamiliarOS skill. Configure this project for OpenCode with a Familiar.
Use the FamiliarOS skill. Debug why familiaros_status is unavailable.
Use the OpenPets skill. Configure this project for Claude Code with a pet.
Use the OpenPets skill. Configure this project for OpenCode with a pet.
Use the OpenPets skill. Debug why openpets_status is unavailable.
```
---
## Star OpenPets
## Create your Familiar
A Familiar is more than a chatbot. More than a virtual companion. More than an assistant.
A Familiar is a persistent AI companion that lives on your desktop, remembers what matters, learns how you work, and grows alongside you.
- **Give it a personality.** Set a character prompt in Settings.
- **Name it.** Your Familiar appears by name in the tray and menus.
- **Let it remember.** Memory is captured automatically from chat and can be edited or searched in Settings.
- **Upload knowledge.** Drop files into the Knowledge Store and your Familiar can retrieve relevant information during future conversations.
- **Talk to it.** Text or voice — your Familiar can listen and speak back.
You stop thinking about models, providers, APIs, prompts, and infrastructure. Instead you think:
> "My Familiar reminded me."
> "My Familiar found that file."
> "My Familiar summarized that document."
> "My Familiar remembers how I like things done."
The best software creates habits. The best companions create relationships.
---
## What FamiliarOS can do
- **Desktop companion** — a small Familiar that idles, reacts, and gives FamiliarOS a friendly presence even before developer tools are configured.
- **Bundled abilities** — first-party plugins add ambient check-ins, break nudges, playful Familiar actions, focus timers, safe little walks, and optional developer notifications.
- **Floating chat** — double-click your Familiar to open an always-on-top chat window with BYOK OpenAPI-compatible providers, conversation history, and optional MCP Tool Server activation.
- **Local memory** — the Familiar remembers facts, preferences, and notes across sessions.
- **Knowledge Store** — upload documents, notes, code, and reference material; your Familiar retrieves relevant context automatically.
- **Voice** — system voices, OpenAI TTS, ElevenLabs, Piper, or OpenAI-compatible endpoints for speech input and output.
- **MCP Tool Servers** — activate filesystem, terminal, web, Git, GitHub, Docker, Playwright, SQLite, memory, and reasoning tools directly from chat.
- **FamiliarOS MCP Server** — external agents (Claude Code, Cursor, OpenCode, Codex CLI) can control your Familiar through a safe, limited tool surface.
- **MCP Toolkit Guide** — a hand-picked guide to popular MCP tools: what each does, what permissions it needs, and how to add it yourself. Nothing installs automatically.
- **Virtual-Familiar status** — hunger, energy, happiness, bond, mood, and level live in the right-click context menu, never blocking chat clicks.
- **Privacy-conscious by design** — automatic hook speech is static and local; prompts, code, logs, command output, URLs, paths, and secrets are not shown in bubbles.
---
## Manage your Familiars
Browse installed Familiars, preview their animations, and choose which companion should follow each coding agent from the FamiliarOS desktop app.
Here is an extra GIF of me starring my own repo to encourage you to do the same. If OpenPets makes your coding setup a little more fun, please give the repo a star.
<p align="center">
<img src="assets/manage-familiars.png" alt="Managing Familiars in the FamiliarOS desktop app" width="100%" />
<img src="assets/star-repo.gif" alt="Starring the OpenPets repository" width="100%" />
</p>
---
## What is OpenPets?
OpenPets is a tray-first desktop companion app. A pet appears on your desktop, keeps you company, and can use bundled abilities for ambient presence, breaks, playful actions, and focus sessions. Coding-agent integrations are still supported as an advanced developer layer.
- **Desktop companion** - a small pet that idles, reacts, and gives OpenPets a friendly presence even before developer tools are configured.
- **Bundled abilities** - first-party plugins can add ambient check-ins, break nudges, playful pet actions, focus timers, safe little walks, and optional developer notifications.
- **Developer integrations** - advanced setup for Claude Code, OpenCode, Cursor, Pi, and MCP-capable tools when you want coding activity to drive the pet.
- **MCP ready** - any MCP-capable agent can send short safe speech bubbles and reactions through the OpenPets MCP server.
- **Floating chat** - double-click the pet to open an always-on-top chat window with BYOK OpenAPI-compatible providers, conversation history, and optional MCP tool activation.
- **Virtual-pet status** - right-click the pet and open the Virtual Pet submenu to see hunger, energy, happiness, bond, mood, and level. No on-pet HUD overlay, so chat clicks stay unobstructed.
- **Single-click petting** - click the pet once to pet it; the virtual-pet bond and happiness increase.
- **Local memory** - the pet remembers facts, preferences, and notes across sessions.
- **Pet-pack friendly** - loads installed animated pet packs and can route a selected agent/project to its own pet window.
- **Privacy-conscious by design** - automatic hook speech is static and local; prompts, code, logs, command output, URLs, paths, and secrets are not shown in bubbles.
## Manage your pets
Browse installed pets, preview their animations, and choose which companion should follow each coding agent from the OpenPets desktop app.
<p align="center">
<img src="assets/manage-pets.png" alt="Managing pets in the OpenPets desktop app" width="100%" />
</p>
## Quick start
Install the desktop app, then optionally connect your coding agent.
### 1. Install FamiliarOS Desktop
### 1. Install OpenPets Desktop
Download the latest app from [FamiliarOS Releases](https://github.com/verticaltension/familiaros/releases/latest):
Download the latest app from [OpenPets Releases](https://github.com/alvinunreal/openpets/releases/latest):
- **macOS Apple Silicon**: `FamiliarOS-*-mac-arm64.dmg`
- **macOS Intel**: `FamiliarOS-*-mac-x64.dmg`
- **Windows**: `FamiliarOS-*-win-x64-setup.exe`
- **Linux**: `FamiliarOS-*-linux-x86_64.AppImage`
- **macOS Apple Silicon**: `OpenPets-*-mac-arm64.dmg`
- **macOS Intel**: `OpenPets-*-mac-x64.dmg`
- **Windows**: `OpenPets-*-win-x64-setup.exe`
- **Linux**: `OpenPets-*-linux-x86_64.AppImage`
Launch FamiliarOS. You should see the desktop Familiar and the FamiliarOS tray/menu-bar icon.
Launch OpenPets. You should see the desktop pet and the OpenPets tray/menu-bar icon.
> Current builds may be unsigned. macOS or Windows may show a security warning the first time you open the app.
If macOS says the app is damaged or should be moved to Trash, remove the quarantine flag and open it again:
```bash
xattr -dr com.apple.quarantine /Applications/FamiliarOS.app
open /Applications/FamiliarOS.app
xattr -dr com.apple.quarantine /Applications/OpenPets.app
open /Applications/OpenPets.app
```
### 2. Optional: connect your agent
FamiliarOS can be driven by Claude Code, OpenCode, Cursor, Pi, or any MCP-capable agent. The desktop app has a central **FamiliarOS MCP Server** panel under **Integrations** where you can choose the command source, override the Node path, pick which Familiar the agent controls, test the server, and copy the MCP JSON.
Use the desktop **Integrations** screen for global setup when available:
For detailed setup, see [`docs/claude-integration.md`](docs/claude-integration.md), [`docs/opencode.md`](docs/opencode.md), and [`docs/cursor.md`](docs/cursor.md).
- **Claude Code** - installs OpenPets MCP, Claude memory instructions, and optional Claude hooks.
- **OpenCode** - installs OpenPets MCP, an OpenCode instruction file, and the `@open-pets/opencode` plugin.
- **MCP Toolkit** - curated setup guidance for Filesystem, Git, GitHub, Playwright, Browser Use, Memory, Fetch, Docker, and adjacent terminal/system MCP layers.
---
<p align="center">
<img src="assets/integrations.png" alt="OpenPets desktop integrations screen" width="100%" />
</p>
## MCP in plain English
For project-local setup, run the CLI from the project you want to configure:
FamiliarOS includes three MCP-related surfaces. They are separate on purpose:
```bash
npx -y @open-pets/cli@latest configure --agent claude --pet <petId>
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
```
### MCP Tool Servers — tools *for* your Familiar's chat
If you prefer a permanent `openpets` shell command, install the CLI once with `npm install -g @open-pets/cli` and replace `npx -y @open-pets/cli@latest` with `openpets`.
The **MCP Tool Servers** panel lets the built-in floating chat do real work. Turn on tools such as filesystem, terminal, web fetch, GitHub, SQLite, Docker, or Playwright, then ask your Familiar things like *"List the files in my home directory"* or *"Summarize that PDF in my Knowledge Store."* The tools run inside the desktop app; no external agent is required.
Project-local setup can create project files such as `.claude/settings.local.json` or `.opencode/opencode.jsonc`. Review them before committing because they may include the selected pet id.
### FamiliarOS MCP Server — your Familiar as a tool *for* external agents
## Advanced: agent integrations
The **FamiliarOS MCP Server** panel exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host. Those agents can then make your Familiar react, speak a safe bubble, or read/write memory. This is a small, controlled bridge — external agents do not get unrestricted access to your desktop.
OpenPets integrations have three layers:
### MCP Toolkit Guide — a safe manual for the wider MCP world
1. **MCP tools** for explicit agent actions.
2. **Agent instructions** so agents know when to use those tools.
3. **Hooks/plugins** for automatic decorative reactions during normal agent work.
The **MCP Toolkit Guide** is not an automatic installer. It is a hand-picked reference that explains what popular MCP tools do, what permissions they need, and how to set them up manually or as a persistent bundle in supported hosts. It is designed so you can adopt the MCP ecosystem deliberately instead of clicking "install everything."
### Claude Code
---
Claude Code integration supports:
- `openpets` MCP setup via Claude Code.
- Managed Claude memory instructions in `~/.claude/CLAUDE.md` and `~/.claude/openpets.md`.
- Managed Claude hooks in `~/.claude/settings.json`.
- Project-local setup through `npx -y @open-pets/cli@latest configure --agent claude --pet <petId>` or the optional global `openpets` CLI.
Typical global MCP command shape:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest
```
With a selected pet:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest --pet <petId>
```
See [`docs/claude-integration.md`](docs/claude-integration.md) for the full file layout, hook mapping, project-local behavior, and safety rules.
### OpenCode
OpenCode integration supports:
- An MCP entry using `@open-pets/cli mcp`.
- A managed `openpets.md` instruction file.
- The `@open-pets/opencode` plugin for automatic reactions.
- Global desktop setup and project-local `.opencode` setup.
Project-local setup:
```bash
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
```
See [`docs/opencode.md`](docs/opencode.md) for global config selection, plugin behavior, project-local setup, and safety rules.
### Generic MCP clients
Any MCP-capable editor or coding agent can talk to OpenPets through the MCP server while the desktop app is running.
```json
{
"mcpServers": {
"openpets": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/mcp@latest"]
}
}
}
```
To target a specific installed non-default pet:
```json
{
"mcpServers": {
"openpets": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/mcp@latest", "--pet", "<petId>"]
}
}
}
```
Available MCP tools:
- `openpets_status` - check whether OpenPets is reachable and which pet is targeted.
- `openpets_react` - set a short reaction on the target pet.
- `openpets_say` - show a short safe speech bubble, optionally with a reaction.
- `openpets_memory_list` - list recent long-term memories stored by OpenPets.
- `openpets_memory_search` - search pet memory for relevant facts, preferences, or notes.
- `openpets_memory_store` - store a durable memory for the pet.
- `openpets_memory_forget` - delete a stored memory by id.
`openpets_say` messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths.
### Curated MCP toolkit
The desktop app also includes a manual-but-curated **MCP Toolkit** panel under **Integrations**. It is there to help you build a practical broader stack without stuffing OpenPets full of unsafe one-click third-party installs.
The toolkit now explicitly offers two paths:
- `Manual Setup` - per-tool guidance, docs links, and copyable snippets
- `Persistent Full Access` - `Install Now` for the supported persistent baseline in hosts such as Claude Code or Codex CLI, plus a matching copyable bundle
- `Vanilla Chat Tools` - activate MCP tools directly inside the floating chat window for autonomous filesystem, terminal, web, and browser tasks
The current supported `Install Now` baseline covers:
- Filesystem
- Playwright
- Memory
- Context7 / Docs
- Fetch / Web when `uvx` is available
- Sequential Thinking
- Browser Use when `uvx` is available, with its own runtime credential still required
The current recommended starter set is:
- Filesystem
- Git
- GitHub
- Playwright
- Browser Use
- SQLite / PostgreSQL
- Memory
- Fetch / Web
- Sequential Thinking
- Docker MCP Toolkit
It also maps the next terminal-oriented layers that usually matter once your workflow grows up a bit:
- Shell / Terminal
- Process & Logs
- System Info
- SSH
- Package Manager
- Tmux
- Kubernetes / Cloud
- CI / CD
- Ghidra
- Binary Analysis
- Network Analysis
## How it works
```text
Claude Code / OpenCode / Pi / MCP client
-> @open-pets/mcp, @open-pets/cli mcp, @open-pets/claude hook, @open-pets/opencode plugin, or @open-pets/pi extension
-> @open-pets/client
-> OpenPets desktop local IPC discovery file
-> OpenPets desktop IPC socket/pipe
-> default pet or selected agent pet window
```
The desktop app writes a local discovery file containing an IPC endpoint and a per-run token. Clients must send that token with every request.
For Windows desktop + WSL agent setups, see [`docs/wsl-ipc.md`](docs/wsl-ipc.md) for the opt-in TCP transport.
When an integration is configured with `--pet <petId>`, OpenPets asks the desktop app for a short-lived lease. Valid installed non-default pets open as explicit agent pet windows. Missing, invalid, broken, built-in, or default pet requests fall back to the desktop default pet.
## Reactions and speech
Automatic hooks are decorative and best-effort. They do not approve, deny, block, or change agent behavior.
Common reaction mapping:
| Agent activity | Reaction |
| --- | --- |
| Prompt/chat starts | `thinking` |
| File edit/write/patch | `editing` |
| Test-like shell command | `testing` |
| Permission request | `waiting` |
| Successful idle/stop | `success` |
| Session/error stop | `error` |
Generic shell activity is intentionally quiet by default. Hook/plugin speech is throttled and selected from local static message pools such as `Approval needed` or `Something failed`.
### Pi extension package
OpenPets includes an experimental Pi extension package at `@open-pets/pi`. Pi support is extension-first rather than MCP-first: the extension listens to Pi lifecycle/tool events and sends local best-effort reactions through `@open-pets/client`.
```bash
pi install npm:@open-pets/pi
pi install -l npm:@open-pets/pi
```
Inside Pi, the extension registers `/openpets status`, `/openpets test`, `/openpets react <reaction>`, and `/openpets say <message>`. Automatic events do not forward prompts, assistant text, tool output, file contents, paths, URLs, or secrets. Real Pi CLI install validation is still required before marking the integration fully supported.
## Development
### Requirements
- Node.js 20+
- pnpm 11+
- TypeScript
No Bun runtime is required for development.
### Install
```bash
pnpm install
pnpm --filter @familiaros/desktop build
pnpm --filter @familiaros/desktop test
```
See `FEATURES.md` and `FEATURE_REGISTRY.md` for a full capability map.
### Run the desktop app
---
```bash
pnpm dev:desktop
```
## License
Equivalent package command:
MIT
```bash
pnpm --filter @open-pets/desktop dev
```
### Checks
```bash
pnpm check
pnpm typecheck
pnpm build
pnpm test
```
OpenPets currently uses lightweight Node contract checks instead of a full test framework. See [`docs/testing.md`](docs/testing.md).
### Package desktop builds
```bash
pnpm package:desktop:dir
pnpm package:desktop
```
Release process details live in [`docs/release.md`](docs/release.md).
## Workspace layout
```text
apps/desktop Electron desktop app
packages/client @open-pets/client, local IPC client
packages/mcp @open-pets/mcp, MCP stdio server
packages/claude @open-pets/claude, Claude command and hook helpers
packages/opencode @open-pets/opencode, OpenCode config and plugin integration
packages/pi @open-pets/pi, Pi extension package
packages/agent-events Shared safe agent event speech helpers
packages/cli @open-pets/cli, user-run CLI, plugin scaffolder/validator, MCP/hook entrypoints
packages/sdk @open-pets/plugin-sdk, plugin SDK v3 types + testing harness
packages/pet-format @open-pets/pet-format, pet/catalog format types
plugins/official First-party plugin source (bundled + catalog)
docs/ Documentation
```
## Documentation
- [`docs/plugins.md`](docs/plugins.md) - plugin platform (SDK v3): manifest, permissions, dev workflow, test kit.
- [`docs/superplugins.md`](docs/superplugins.md) - SDK v3 design rationale and feature spec.
- [`docs/claude-integration.md`](docs/claude-integration.md) - Claude Code setup, MCP, memory, hooks, and safety.
- [`docs/opencode.md`](docs/opencode.md) - OpenCode global/project setup, plugin behavior, and safety.
- [`docs/wsl-ipc.md`](docs/wsl-ipc.md) - Windows desktop + WSL MCP transport setup.
- [`docs/testing.md`](docs/testing.md) - test/check strategy.
- [`docs/release.md`](docs/release.md) - desktop release process.
- [`docs/workflow.md`](docs/workflow.md) - project workflow notes.
## Safety and privacy notes
- OpenPets local IPC is local-only and protected by a per-run token.
- Hook/plugin errors are swallowed unless debug logging is enabled.
- Automatic speech is static and local; it does not include model-generated prompt text.
- Tool inputs and command text are used only for coarse reaction classification.
- Managed setup preserves unrelated user config and removes only OpenPets-managed entries.
- Speech validation rejects code-like, secret-like, URL-like, path-like, or multiline messages.

View file

@ -1,10 +1,10 @@
# FamiliarOS — Our Additions (Isolated README)
# OpenPets — Our Additions (Isolated README)
This README describes only the capabilities added by our fork. It is kept separate from the upstream `README.md` so the original maintainer can review and adopt changes independently.
## New: Floating Chat Window
Double-click the desktop familiar to open an always-on-top floating chat window.
Double-click the desktop pet to open an always-on-top floating chat window.
- BYOK OpenAPI-compatible chat (OpenAI, OpenRouter, Moonshot/Kimi, LiteLLM, vLLM, Azure, custom).
- Conversation history rendered as a styled list with role badges and timestamps.
@ -16,98 +16,49 @@ Double-click the desktop familiar to open an always-on-top floating chat window.
## New: Local Memory
FamiliarOS now remembers things for you.
OpenPets now remembers things for you.
- Durable local memory store with automatic capture from chat prompts.
- Relevance-based retrieval into future system prompts.
- Memory kinds: preference, identity, fact, note.
- In-app memory viewer in Settings for search, edit, and delete.
- Also exposed through existing FamiliarOS MCP tools (`familiaros_memory_*`).
- Also exposed through existing OpenPets MCP tools (`openpets_memory_*`).
## New: Knowledge Store
## New: Vanilla Chat MCP Tools
Store files and add manual knowledge entries that the assistant can reference during chat.
Activate MCP tools directly inside the floating chat window for autonomous task execution.
- Upload files from Settings > Knowledge Store or store attachments directly from the floating chat window.
- Text-like files are indexed and searched by file name and extracted content.
- Relevant files are injected into the assistant's system instructions automatically.
- Combined search across stored files and FamiliarOS memories.
- Filesystem, Terminal, Memory, Fetch/Web, Sequential Thinking, Playwright, Git, GitHub, Docker, SQLite.
- Tiered browser in Settings/Integrations: Starter, Terminal & Systems, Advanced.
- Tools run through an internal stdio MCP client in the main process.
## New: MCP Tool Servers — Tools for Your Familiar's Chat
The **MCP Tool Servers** panel gives the built-in floating chat the ability to do real work on your local machine. It is separate from the external-agent bridge.
- Activate tools such as filesystem, terminal, web fetch, sequential thinking, Playwright, Git, GitHub, Docker, and SQLite.
- The tools run through an internal stdio MCP client in the main process.
- Once enabled, you can ask your Familiar things like *"List the files in my home directory,"* *"Summarize that PDF,"* or *"Run this test script."*
- Tools can be turned on or off individually, so you only grant the access you actually want.
## New: FamiliarOS MCP Server Panel — Your Familiar as a Tool for External Agents
The **FamiliarOS MCP Server** panel exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host.
- Command mode selector (published / bundled / local) in one place.
- Node path override and Familiar routing in one place.
- One-click **Test Server** health check.
- One-click **Copy MCP JSON** for pasting into your agent's MCP configuration.
- This is a small, controlled bridge: external agents can make your Familiar react, speak a safe bubble, or read/write memory, but they do not get unrestricted access to your desktop.
## New: MCP Toolkit Guide — A Safe Manual for the Wider MCP World
The **MCP Toolkit Guide** is a hand-picked reference surface for adopting the broader MCP ecosystem deliberately.
- It is **not** an automatic installer; it explains what popular MCP tools do, what permissions they need, and how to set them up manually.
- Each entry includes copy-paste snippets and guidance for supported hosts.
- Use it to build a persistent tool bundle or to understand a tool before you enable it.
## New: Familiar Scale
## New: Pet Scale
- Continuous scale slider from 0.16x to 10x in Settings.
- Window dynamically resizes so the familiar is never clipped.
- Window dynamically resizes so the pet is never clipped.
## New: Virtual Familiar Status
## New: Virtual Pet Status
- Bundled virtual-familiar plugin tracks hunger, energy, happiness, and affection.
- Status values live in the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level) instead of an on-familiar HUD overlay, so double-click chat stays unobstructed.
- Feed, play, familiar, and nap commands are available from the familiar context menu.
- Bundled virtual-pet plugin tracks hunger, energy, happiness, and affection.
- Status values live in the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level) instead of an on-pet HUD overlay, so double-click chat stays unobstructed.
- Feed, play, pet, and nap commands are available from the pet context menu.
## New: Familiar Character & Base Instructions
## New: Pet Character & Base Instructions
- Give your Familiar a custom system-prompt style character in Settings.
- Toggle whether the default FamiliarOS behavior instructions are included in every chat.
## New: Custom Familiar Name
- Name your default Familiar in Settings → General.
- The chosen name appears in the tray tooltip, tray context menu, and anywhere the default Familiar is referenced.
- If no name is set, the menu falls back to the Familiar's display name or "Built-in Familiar."
## New: Text-to-Speech (Phase 2)
The familiar can now speak aloud using a configurable TTS provider.
- Settings > **Text-to-Speech** chooses between system OS voice, OpenAI TTS, ElevenLabs, Piper (local), or an OpenAI-compatible endpoint.
- Per-provider voice, model, speed, endpoint preset, and API-key storage.
- Test and Stop buttons preview the voice in Settings.
- Speech is skipped during quiet hours and uses the same safety boundaries as bubbles.
- Plugins with the `voice:speak` permission can ask the familiar to speak.
- Give your pet a custom system-prompt style character in Settings.
- Toggle whether the default OpenPets behavior instructions are included in every chat.
## New: Multi-Screen Awareness
The familiar now stays on the display it is currently near, rather than always snapping to the primary display.
The pet now stays on the display it is currently near, rather than always snapping to the primary display.
## Installing these changes
No additional setup is required beyond the normal FamiliarOS desktop app. The new surfaces appear automatically:
No additional setup is required beyond the normal OpenPets desktop app. The new surfaces appear automatically:
- Single-click the familiar → familiar the virtual familiar.
- Double-click the familiar → floating chat.
- Right-click the familiar → Virtual Familiar status and care commands.
- Single-click the pet → pet the virtual pet.
- Double-click the pet → floating chat.
- Right-click the pet → Virtual Pet status and care commands.
- Settings → OpenAPI Chat for model/credential setup.
- Settings → Text-to-Speech for voice provider and test preview.
- Settings → Memory for memory management.
- Settings → Knowledge Store for file-based knowledge.
- Integrations → MCP Tool Servers for built-in chat tool activation.
- Integrations → FamiliarOS MCP Server for external-agent bridge configuration.
- Integrations → MCP Toolkit Guide for optional persistent bundles and copy-paste snippets.
- Integrations → MCP Toolkit for tool activation.

View file

@ -2,27 +2,27 @@
## Responsibility
Container for deployable application packages selected by the pnpm workspace `apps/*` glob. Currently hosts the FamiliarOS desktop Electron application, which integrates shared packages, local IPC, familiar windows, and desktop plugin support into the user-facing app.
Container for deployable application packages selected by the pnpm workspace `apps/*` glob. Currently hosts the OpenPets desktop Electron application, which integrates shared packages, local IPC, pet windows, and desktop plugin support into the user-facing app.
## Design Patterns
- **Workspace App Boundary**: `pnpm-workspace.yaml` includes `apps/*`, so each app directory is an independently buildable workspace package.
- **Workspace Dependencies**: apps consume shared `packages/` modules through `workspace:*` dependencies.
- **Electron-First Architecture**: the desktop app uses an Electron main process, tray-centric UX, and isolated renderer windows rather than a traditional main window.
- **Service-Oriented Desktop Modules**: desktop features are split into main-process services for state, IPC, familiar controllers, catalog installation, setup flows, and plugin management/runtime.
- **Service-Oriented Desktop Modules**: desktop features are split into main-process services for state, IPC, pet controllers, catalog installation, setup flows, and plugin management/runtime.
- **Security-First Renderers**: CSP headers, sandboxed renderers, context isolation, and disabled `nodeIntegration` protect UI surfaces including plugin-related windows.
## Data & Control Flow
1. Workspace tooling discovers application packages via the `apps/*` pnpm workspace glob.
2. The desktop app bootstraps from `apps/desktop/src/main.ts`, initializes user data and app state, then creates tray and renderer windows.
3. Agent commands flow through the local IPC server into lease-managed familiar controllers and window updates.
4. Familiar assets flow from built-in assets, local development sources, or downloaded catalog packages into installation/state services and renderer windows.
5. Desktop plugin manifests/configuration flow through plugin loader, package, catalog, state, service, and runtime modules before exposing controlled familiar APIs and plugin UI.
3. Agent commands flow through the local IPC server into lease-managed pet controllers and window updates.
4. Pet assets flow from built-in assets, local development sources, or downloaded catalog packages into installation/state services and renderer windows.
5. Desktop plugin manifests/configuration flow through plugin loader, package, catalog, state, service, and runtime modules before exposing controlled pet APIs and plugin UI.
## Integration Points
- **Workspace packages**: consumes `@familiaros/agent-events`, `@familiaros/claude`, `@familiaros/cli`, `@familiaros/cursor`, `@familiaros/mcp`, `@familiaros/opencode`, and IPC/client-facing shared APIs.
- **Desktop submodules**: `apps/desktop/src/` provides lifecycle, state, tray/windows, setup integrations, familiar installation, local IPC, and plugin runtime services.
- **External services**: GitHub Releases API for update checks, `familiaros.dev` for catalog data, and `zip.familiaros.dev` for familiar downloads.
- **Workspace packages**: consumes `@open-pets/agent-events`, `@open-pets/claude`, `@open-pets/cli`, `@open-pets/cursor`, `@open-pets/mcp`, `@open-pets/opencode`, and IPC/client-facing shared APIs.
- **Desktop submodules**: `apps/desktop/src/` provides lifecycle, state, tray/windows, setup integrations, pet installation, local IPC, and plugin runtime services.
- **External services**: GitHub Releases API for update checks, `openpets.dev` for catalog data, and `zip.openpets.dev` for pet downloads.
- **System surfaces**: Claude Code CLI, OpenCode CLI, OS tray/dock, renderer windows, and filesystem locations such as `userData`, `~/.codex`, and `~/.claude`.

View file

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 397 KiB

View file

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -1,51 +1,41 @@
{
"version": 2,
"generatedAt": "2026-05-05T08:55:53.722Z",
"familiars": [
"pets": [
{
"id": "lantern",
"displayName": "Lantern",
"description": "A calm desk companion with a soft glow and rounded paper-cut silhouette.",
"preview": "https://familiaros.dev/familiars/lantern-23e05847/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/lantern-23e05847/lantern.zip",
"category": "western",
"original": true
"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": "moss",
"displayName": "Moss",
"description": "A tiny green studio spirit with leaf-soft idle motion and quiet focus energy.",
"preview": "https://familiaros.dev/familiars/moss-904b393f/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/moss-904b393f/moss.zip",
"category": "western",
"original": true
"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": "orbit",
"displayName": "Orbit",
"description": "A compact star-tracker familiar with a gentle scan loop and bright focus ring.",
"preview": "https://familiaros.dev/familiars/orbit-de2f300f/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/orbit-de2f300f/orbit.zip",
"category": "western",
"featured": true
"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": "ember",
"displayName": "Ember",
"description": "A warm little spark familiar with crisp motion beats for active work states.",
"preview": "https://familiaros.dev/familiars/ember-779d5202/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/ember-779d5202/ember.zip",
"category": "asian",
"original": true
"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": "rivulet",
"displayName": "Rivulet",
"description": "A flowing ink-drop companion built for softer waiting, review, and memory moments.",
"preview": "https://familiaros.dev/familiars/rivulet-3f6746e0/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/rivulet-3f6746e0/rivulet.zip",
"category": "asian",
"featured": true
"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

@ -2,11 +2,11 @@
## Responsibility
FamiliarOS desktop companion application. Tray-first Electron app providing animated desktop familiars that react to coding agent events. Manages familiar installations, the React/Tailwind Control Center, plugin automation/runtime, agent integrations (Claude Code, OpenCode, Cursor, Pi guidance), and local IPC for CLI communication.
OpenPets desktop companion application. Tray-first Electron app providing animated desktop pets that react to coding agent events. Manages pet installations, the React/Tailwind Control Center, plugin automation/runtime, agent integrations (Claude Code, OpenCode, Cursor, Pi guidance), and local IPC for CLI communication.
## Design
- **Tray-First UX**: No default main window; tray actions open the singleton React/Tailwind Control Center and route directly to Dashboard, Familiars, Integrations, Plugins, and Settings.
- **Tray-First UX**: No default main window; tray actions open the singleton React/Tailwind Control Center and route directly to Dashboard, Pets, Integrations, Plugins, and Settings.
- **Single Instance**: Uses `app.requestSingleInstanceLock()` with second-instance focusing
- **Security Model**:
- Sandboxed renderers with contextIsolation
@ -15,13 +15,13 @@ FamiliarOS desktop companion application. Tray-first Electron app providing anim
- Mock keychain to prevent OS credential prompts
- IPC network security: loopback/private address filtering for TCP mode
- **State Management**: File-based JSON state with atomic writes (temp + rename)
- **Familiar Architecture**:
- Default familiar (always visible when enabled)
- Agent familiars (lease-based, appear on explicit agent requests)
- Built-in fallback familiar (bundled spritesheet)
- **Pet Architecture**:
- Default pet (always visible when enabled)
- Agent pets (lease-based, appear on explicit agent requests)
- Built-in fallback pet (bundled spritesheet)
- Speech bubbles with reaction messages and status badges
- User-configurable reaction-to-animation mapping
- **Lease Manager**: 15s TTL leases for agent familiar routing with heartbeat renewal
- **Lease Manager**: 15s TTL leases for agent pet routing with heartbeat renewal
- **Logging**: Structured logging with scopes, log rotation (2MB max), and sensitive data redaction
- **Plugin Subsystem**: Declarative manifest plugins and JavaScript plugin hosting with permission approval, config schemas, command/status surfaces, catalog/local installs, SDK bridge quotas, storage, schedules, restricted HTTPS fetch, and safe path/ZIP/manifest validation
@ -29,33 +29,33 @@ FamiliarOS desktop companion application. Tray-first Electron app providing anim
**Startup**: `main.ts``installAppLifecycle()``initializeAppState()``initializeLogger()``createAppTray()``startLocalIpcServer()` → initialize plugin service with JavaScript host/SDK bridge → optionally `showDefaultPet()`
**Familiar Display**: IPC Request → `local-ipc.ts``LeaseManager.acquire()``agent-familiar-controller.ts` → `familiar-window.ts` → HTML/CSS spritesheet animation with reaction-to-animation mapping
**Pet Display**: IPC Request → `local-ipc.ts``LeaseManager.acquire()``agent-pet-controller.ts` → `pet-window.ts` → HTML/CSS spritesheet animation with reaction-to-animation mapping
**Installation**: Catalog fetch (V3 with pagination fallback to V2) → ZIP download → `yauzl` extraction → validation → state update → tray refresh
**Agent Setup**: UI → `agent-setup.ts` → Claude/OpenCode/Cursor CLI detection → MCP config modification → hooks installation → memory file management
**Control Center**: Tray route → `openControlCenterWindow(route)``windows.ts` loads Vite renderer and sends route events → `control-center-preload.cjs` exposes narrow page APIs → React Dashboard/Familiars/Integrations/Plugins/Settings routes render snapshots and invoke actions.
**Control Center**: Tray route → `openControlCenterWindow(route)``windows.ts` loads Vite renderer and sends route events → `control-center-preload.cjs` exposes narrow page APIs → React Dashboard/Pets/Integrations/Plugins/Settings routes render snapshots and invoke actions.
**Plugins**: Control Center plugins route → `plugin-service.ts` → catalog or local manifest/entry loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers or starts `plugin-js-host.ts``plugin-sdk-bridge.ts` applies approved SDK calls to familiar/schedule/storage/command/status/network APIs
**Plugins**: Control Center plugins route → `plugin-service.ts` → catalog or local manifest/entry loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers or starts `plugin-js-host.ts``plugin-sdk-bridge.ts` applies approved SDK calls to pet/schedule/storage/command/status/network APIs
## Integration Points
- **Workspace Packages**: `@familiaros/agent-events`, `@familiaros/claude`, `@familiaros/cli`, `@familiaros/cursor`, `@familiaros/mcp`, `@familiaros/opencode`
- **Workspace Packages**: `@open-pets/agent-events`, `@open-pets/claude`, `@open-pets/cli`, `@open-pets/cursor`, `@open-pets/mcp`, `@open-pets/opencode`
- **External Services**:
- `https://familiaros.dev/familiars/catalog.v2.json` (familiar catalog V2)
- `https://familiaros.dev/familiars/catalog.v3.json` (familiar catalog V3 with pagination)
- `https://familiaros.dev/plugins/catalog.v1.json` (plugin catalog V1)
- `https://zip.familiaros.dev/familiars/{id}.zip` (familiar downloads)
- `https://zip.familiaros.dev/plugins/{id}.zip` (plugin downloads)
- `https://openpets.dev/pets/catalog.v2.json` (pet catalog V2)
- `https://openpets.dev/pets/catalog.v3.json` (pet catalog V3 with pagination)
- `https://openpets.dev/plugins/catalog.v1.json` (plugin catalog V1)
- `https://zip.openpets.dev/pets/{id}.zip` (pet downloads)
- `https://zip.openpets.dev/plugins/{id}.zip` (plugin downloads)
- GitHub API (release checks)
- **System Integration**:
- Claude Code: `~/.claude/CLAUDE.md`, `~/.claude/settings.json`, `claude mcp` commands
- OpenCode: `~/.opencode/config.json`
- Cursor: `~/.cursor/mcp.json`, `.cursor/rules/familiaros.mdc`
- Codex: `~/.codex/familiars/` (local familiar development)
- Cursor: `~/.cursor/mcp.json`, `.cursor/rules/openpets.mdc`
- Codex: `~/.codex/pets/` (local pet development)
- IPC: Discovery file at platform-specific path, Unix socket/Windows named pipe/TCP
- Logs: `userData/logs/familiaros.log`
- Logs: `userData/logs/openpets.log`
- **Build**: `electron-builder` with ASAR, cross-platform (macOS/Windows/Linux)
## Key Files
@ -63,32 +63,32 @@ FamiliarOS desktop companion application. Tray-first Electron app providing anim
- `main.ts`: Entry point, lifecycle coordination
- `tray.ts`: System tray icon and menu
- `windows.ts`: Control Center BrowserWindow management, Dashboard snapshot, route targeting, IPC handlers, and internal protocols
- `renderer/`: React/Tailwind Control Center for Dashboard, Familiars, Integrations, Plugins, and Settings
- `renderer/`: React/Tailwind Control Center for Dashboard, Pets, Integrations, Plugins, and Settings
- `local-ipc.ts`: TCP/Unix socket server for CLI communication
- `lease-manager.ts`: Familiar routing lease lifecycle
- `familiar-window.ts`: Familiar rendering (transparent frameless windows, CSS sprite animation, speech bubbles, status badges)
- `default-familiar-controller.ts`/`agent-familiar-controller.ts`: Familiar visibility/state management with transient displays
- `lease-manager.ts`: Pet routing lease lifecycle
- `pet-window.ts`: Pet rendering (transparent frameless windows, CSS sprite animation, speech bubbles, status badges)
- `default-pet-controller.ts`/`agent-pet-controller.ts`: Pet visibility/state management with transient displays
- `app-state.ts`: Persistent state management (JSON file)
- `agent-setup.ts`: Claude/OpenCode/Cursor integration logic
- `plugin-service.ts`: Plugin orchestration for snapshots, enable/config/reload, command execution, catalog install/update/uninstall, local loading, permission approval, JavaScript host wiring, and runtime reloads
- `plugin-manifest.ts`: `familiaros.plugin.json` v1/v2 schema/types/validator for declarative timer plugins and JavaScript SDK plugins, config fields, permissions, commands/status/network, and actions
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timers and starts JavaScript plugin hosts for approved familiar/schedule/storage/command/status/network actions
- `plugin-manifest.ts`: `openpets.plugin.json` v1/v2 schema/types/validator for declarative timer plugins and JavaScript SDK plugins, config fields, permissions, commands/status/network, and actions
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timers and starts JavaScript plugin hosts for approved pet/schedule/storage/command/status/network actions
- `plugin-state.ts`: Atomic JSON state store for installed plugins, enabled flag, approved permissions, config, broken state, and update metadata
- `plugin-config.ts`: Plugin default/effective config validation and config reference resolution
- `plugin-catalog.ts`/`plugin-catalog-validation.ts`: Plugin catalog fetch/cache and strict catalog entry validation
- `plugin-package.ts`: Catalog plugin ZIP download, SHA-256 verification, manifest extraction, install, and safe uninstall path resolution
- `plugin-local-loader.ts`: Local developer plugin folder validation and manifest snapshotting into app data
- `plugin-manifest-reader.ts`: Safe installed-manifest reader enforcing allowed roots, size limits, path containment, and expected id/version
- `plugin-familiar-api.ts`: Runtime bridge from plugin actions to default familiar speech/reaction APIs
- `plugin-pet-api.ts`: Runtime bridge from plugin actions to default pet speech/reaction APIs
- `plugin-js-host.ts`: Hidden sandboxed BrowserWindow host for JavaScript plugin entry modules, SDK IPC tokening, session hardening, startup handshake, and teardown
- `plugin-sdk-bridge.ts`: Permission-checked SDK API for JavaScript plugins with quotas, plugin storage, schedules, config listeners, commands/status, logs, and restricted HTTPS fetch
- `familiar-installation.ts`: Catalog ZIP download and extraction
- `codex-familiars.ts`: Local Codex familiar import
- `pet-installation.ts`: Catalog ZIP download and extraction
- `codex-pets.ts`: Local Codex pet import
- `catalog.ts`: Remote catalog fetching with V3 pagination and fixture fallback
- `logger.ts`: Structured logging with scopes (app, ipc, lease, familiar, state, tray, ui)
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet, state, tray, ui)
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping with user overrides
- `reaction-messages.ts`: Message pools for each reaction type
- `control-center-preload.cjs`/`familiar-preload.cjs`/`plugin-sdk-preload.cjs`: Narrow contextBridge APIs for the Control Center, familiar windows, and plugin SDK host; the legacy `preload.cjs` task-window bridge and `plugins-window.ts` UI have been removed
- `control-center-preload.cjs`/`pet-preload.cjs`/`plugin-sdk-preload.cjs`: Narrow contextBridge APIs for the Control Center, pet windows, and plugin SDK host; the legacy `preload.cjs` task-window bridge and `plugins-window.ts` UI have been removed
- `electron-builder.yml`: Packaging configuration
- `scripts/release-local.mjs`: macOS-local release automation with GitHub draft creation
- `contracts/catalog-fixture.contract.ts`: Catalog V2 validation contract tests against fixture data
@ -97,7 +97,7 @@ FamiliarOS desktop companion application. Tray-first Electron app providing anim
## Test Structure
- **Behavior tests** (`tests/*.test.ts`): Unit tests for lease manager, state management, version checking, ZIP safety, Codex familiars, Claude memory, and reaction animation mapping. Compiled to `.test-dist/tests/`.
- **Behavior tests** (`tests/*.test.ts`): Unit tests for lease manager, state management, version checking, ZIP safety, Codex pets, Claude memory, and reaction animation mapping. Compiled to `.test-dist/tests/`.
- **Contract tests** (`contracts/*.contract.ts`): Public API boundary validation for catalog fixtures, IPC protocol, and plugin manifest schema. Compiled to `.test-dist/contracts/`.
- **Runtime checks** (`src/check-*.ts`): Remaining runtime validation checks compiled to `dist/`.
- **Test runner** (`scripts/run-tests.mjs`): Orchestrates preload syntax checks → test compilation → behavior tests → contract tests → dist checks.

View file

@ -3,25 +3,16 @@ import { readFile } from "node:fs/promises";
import { validateCatalogV2 } from "../src/catalog-validation.js";
const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown;
const validated = validateCatalogV2(fixture);
if (!validated.familiars.some((familiar) => familiar.original === true)) {
throw new Error("Catalog fixture lost original familiar metadata.");
}
if (!validated.familiars.some((familiar) => familiar.featured === true)) {
throw new Error("Catalog fixture lost featured familiar metadata.");
}
if (!validated.familiars.every((familiar) => familiar.category === "western" || familiar.category === "asian")) {
throw new Error("Catalog fixture lost category metadata.");
}
validateCatalogV2(fixture);
const generatedAt = new Date().toISOString();
const invalidCases: readonly unknown[] = [
{ version: 2, generatedAt, familiars: [{ id: "Bad ID", displayName: "Bad", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "dup", displayName: "Dup", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }, { id: "dup", displayName: "Dup 2", description: "", preview: "https://familiaros.dev/familiars/y/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/y/y.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "http", displayName: "Http", description: "", preview: "http://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "host", displayName: "Host", description: "", preview: "https://evil.example/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "builtin", displayName: "Builtin", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, 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, 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, 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, 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, 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) {

View file

@ -2,7 +2,7 @@
## Responsibility
Public API boundary contract tests for the FamiliarOS desktop application. These contracts validate that critical external-facing interfaces (catalog data, IPC protocol) conform to expected schemas and behavior. Contract tests are compiled to `.test-dist/contracts/` and executed during `pnpm test`.
Public API boundary contract tests for the OpenPets desktop application. These contracts validate that critical external-facing interfaces (catalog data, IPC protocol) conform to expected schemas and behavior. Contract tests are compiled to `.test-dist/contracts/` and executed during `pnpm test`.
## Design Patterns
@ -32,8 +32,8 @@ Test parseIpcRequest() with valid/invalid tokens, versions, methods
**Plugin Manifest Contract** (`plugin-manifest.contract.ts`):
```
Assert familiaros.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and familiar actions
Assert openpets.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and pet actions
→ Test config references, unknown fields, runtime/permission constraints, deferred config types/features, defaults, options, timer intervals, and required permissions
→ Assert each invalid manifest reports the expected error code
```

View file

@ -12,10 +12,10 @@ const valid = {
};
parseIpcRequest(JSON.stringify(valid), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "familiars.list" }), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "pets.list" }), token);
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, token: "bad" }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, version: 2 }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, method: "familiar.install" }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, method: "pet.install" }), token));
assert.throws(() => parseIpcRequest("not json", token));
validateReaction("testing");

View file

@ -1,6 +1,6 @@
import { openPetsPluginManifestFilename, validatePluginManifest } from "../src/plugin-manifest.js";
assertEqual(openPetsPluginManifestFilename, "familiaros.plugin.json");
assertEqual(openPetsPluginManifestFilename, "openpets.plugin.json");
const validManifest = {
manifestVersion: 1,
@ -8,7 +8,7 @@ const validManifest = {
name: "Stretch Timer",
version: "1.0.0",
runtime: "declarative",
permissions: ["timer", "familiar:speak", "familiar:reaction"],
permissions: ["timer", "pet:speak", "pet:reaction"],
configSchema: {
intervalMinutes: { type: "number", label: "Interval", default: 15 },
message: { type: "text", label: "Message", default: "Time to stretch!" },
@ -24,8 +24,8 @@ const validManifest = {
on: "timer",
everyMinutes: { config: "intervalMinutes" },
actions: [
{ type: "familiar.speak", message: "Time to stretch!" },
{ type: "familiar.react", reaction: "celebrating" },
{ type: "pet.speak", message: "Time to stretch!" },
{ type: "pet.react", reaction: "celebrating" },
],
},
],
@ -34,18 +34,18 @@ const validManifest = {
assertValid(validManifest);
assertValid({ ...validManifest, icon: "bell" });
assertInvalid({ ...validManifest, icon: "https://example.com/icon.svg" }, "invalid_icon");
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak", "network"], network: { hosts: ["api.example.com"] } });
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "<svg>", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak"] }, "invalid_icon");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak"] }, "invalid_runtime");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["familiar:speak"] }, "invalid_entry");
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } });
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "<svg>", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_icon");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_runtime");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["pet:speak"] }, "invalid_entry");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["*.example.com"] } }, "invalid_network_host");
assertValid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "message" } }, { type: "familiar.react", reaction: { config: "mood" } }] }] });
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "message", extra: true } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "missing" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "mood" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "message" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", default: "celebrate", options: [{ label: "Celebrate", value: "celebrate" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", options: [{ label: "Celebrate", value: "celebrating" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertValid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message" } }, { type: "pet.react", reaction: { config: "mood" } }] }] });
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message", extra: true } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "missing" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "mood" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "message" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", default: "celebrate", options: [{ label: "Celebrate", value: "celebrate" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", options: [{ label: "Celebrate", value: "celebrating" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, extra: true }, "unknown_field");
assertInvalid(
{
@ -64,7 +64,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: "Hi", unsafe: true }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: "Hi", unsafe: true }] }],
},
"unknown_field",
);
@ -77,7 +77,7 @@ assertInvalid(
);
assertInvalid({ ...validManifest, runtime: "javascript" }, "unsupported_runtime");
assertInvalid({ ...validManifest, permissions: ["timer", "network"] }, "invalid_permission");
assertInvalid({ ...validManifest, permissions: ["timer", "familiar:speak", "timer", "familiar:reaction"] }, "duplicate_permission");
assertInvalid({ ...validManifest, permissions: ["timer", "pet:speak", "timer", "pet:reaction"] }, "duplicate_permission");
assertInvalid(
{
...validManifest,
@ -96,7 +96,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.badge", text: "Busy" }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.badge", text: "Busy" }] }],
},
"invalid_action",
);
@ -175,7 +175,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["familiar:speak", "familiar:reaction"],
permissions: ["pet:speak", "pet:reaction"],
},
"missing_permission",
);
@ -183,7 +183,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "familiar:reaction"],
permissions: ["timer", "pet:reaction"],
},
"missing_permission",
);
@ -191,7 +191,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "familiar:speak"],
permissions: ["timer", "pet:speak"],
},
"missing_permission",
);

View file

@ -1,83 +1,69 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
getPetsState: () => ipcRenderer.invoke("familiaros:get-familiars-state"),
getDashboardSnapshot: () => ipcRenderer.invoke("familiaros:get-dashboard-snapshot"),
getSettingsState: () => ipcRenderer.invoke("familiaros:get-settings-state"),
getOpenApiChatSettings: () => ipcRenderer.invoke("familiaros:get-openapi-chat-settings"),
getI18n: () => ipcRenderer.invoke("familiaros:get-i18n"),
updatePreferences: (patch) => ipcRenderer.invoke("familiaros:update-preferences", patch),
saveOpenApiCredential: (apiKey) => ipcRenderer.invoke("familiaros:save-openapi-credential", apiKey),
clearOpenApiCredential: () => ipcRenderer.invoke("familiaros:clear-openapi-credential"),
getReactionAnimationSettings: () => ipcRenderer.invoke("familiaros:get-reaction-animation-settings"),
getLaunchAtLogin: () => ipcRenderer.invoke("familiaros:get-launch-at-login"),
setLaunchAtLogin: (enabled) => ipcRenderer.invoke("familiaros:set-launch-at-login", enabled),
getUpdateStatus: () => ipcRenderer.invoke("familiaros:get-update-status"),
checkForUpdates: () => ipcRenderer.invoke("familiaros:check-for-updates"),
openUpdateReleasePage: () => ipcRenderer.invoke("familiaros:open-update-release-page"),
resetDefaultPetPosition: () => ipcRenderer.invoke("familiaros:reset-default-familiar-position"),
getPluginsSnapshot: () => ipcRenderer.invoke("familiaros:plugins-snapshot"),
getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("familiaros:plugins-catalog-snapshot", refresh),
setPluginEnabled: (id, enabled) => ipcRenderer.invoke("familiaros:plugins-set-enabled", id, enabled),
savePluginConfig: (id, config) => ipcRenderer.invoke("familiaros:plugins-save-config", id, config),
pickPluginConfigSound: (id) => ipcRenderer.invoke("familiaros:plugins-pick-config-sound", id),
reloadPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-reload", id),
executePluginCommand: (id, commandId, args) => ipcRenderer.invoke("familiaros:plugins-execute-command", id, commandId, args),
loadLocalPlugin: () => ipcRenderer.invoke("familiaros:plugins-load-local"),
installCatalogPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-install-catalog", id),
updateCatalogPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-update-catalog", id),
uninstallPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-uninstall", id),
getPluginInspector: (id) => ipcRenderer.invoke("familiaros:plugins-inspector", id),
getPluginPlatformSettings: () => ipcRenderer.invoke("familiaros:plugin-platform-settings-get"),
updatePluginPlatformSettings: (patch) => ipcRenderer.invoke("familiaros:plugin-platform-settings-update", patch),
setPluginAiApiKey: (key) => ipcRenderer.invoke("familiaros:plugin-platform-ai-key-set", key),
getPluginAiApiKeyStatus: () => ipcRenderer.invoke("familiaros:plugin-platform-ai-key-status"),
getCatalog: () => ipcRenderer.invoke("familiaros:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("familiaros:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("familiaros:get-catalog-search"),
getCodexPets: () => ipcRenderer.invoke("familiaros:get-codex-familiars"),
setDefaultPet: (petId) => ipcRenderer.invoke("familiaros:set-default-familiar", petId),
installPet: (petId) => ipcRenderer.invoke("familiaros:install-familiar", petId),
installLocalPet: () => ipcRenderer.invoke("familiaros:install-local-familiar"),
importCodexPet: (petId) => ipcRenderer.invoke("familiaros:import-codex-familiar", petId),
openGallery: () => ipcRenderer.invoke("familiaros:open-gallery"),
removePet: (petId) => ipcRenderer.invoke("familiaros:remove-familiar", petId),
getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"),
getDashboardSnapshot: () => ipcRenderer.invoke("openpets:get-dashboard-snapshot"),
getSettingsState: () => ipcRenderer.invoke("openpets:get-settings-state"),
getOpenApiChatSettings: () => ipcRenderer.invoke("openpets:get-openapi-chat-settings"),
getI18n: () => ipcRenderer.invoke("openpets:get-i18n"),
updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch),
saveOpenApiCredential: (apiKey) => ipcRenderer.invoke("openpets:save-openapi-credential", apiKey),
clearOpenApiCredential: () => ipcRenderer.invoke("openpets:clear-openapi-credential"),
getReactionAnimationSettings: () => ipcRenderer.invoke("openpets:get-reaction-animation-settings"),
getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"),
setLaunchAtLogin: (enabled) => ipcRenderer.invoke("openpets:set-launch-at-login", enabled),
getUpdateStatus: () => ipcRenderer.invoke("openpets:get-update-status"),
checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"),
openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"),
resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"),
getPluginsSnapshot: () => ipcRenderer.invoke("openpets:plugins-snapshot"),
getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("openpets:plugins-catalog-snapshot", refresh),
setPluginEnabled: (id, enabled) => ipcRenderer.invoke("openpets:plugins-set-enabled", id, enabled),
savePluginConfig: (id, config) => ipcRenderer.invoke("openpets:plugins-save-config", id, config),
pickPluginConfigSound: (id) => ipcRenderer.invoke("openpets:plugins-pick-config-sound", id),
reloadPlugin: (id) => ipcRenderer.invoke("openpets:plugins-reload", id),
executePluginCommand: (id, commandId, args) => ipcRenderer.invoke("openpets:plugins-execute-command", id, commandId, args),
loadLocalPlugin: () => ipcRenderer.invoke("openpets:plugins-load-local"),
installCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-install-catalog", id),
updateCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-update-catalog", id),
uninstallPlugin: (id) => ipcRenderer.invoke("openpets:plugins-uninstall", id),
getPluginInspector: (id) => ipcRenderer.invoke("openpets:plugins-inspector", id),
getPluginPlatformSettings: () => ipcRenderer.invoke("openpets:plugin-platform-settings-get"),
updatePluginPlatformSettings: (patch) => ipcRenderer.invoke("openpets:plugin-platform-settings-update", patch),
setPluginAiApiKey: (key) => ipcRenderer.invoke("openpets:plugin-platform-ai-key-set", key),
getPluginAiApiKeyStatus: () => ipcRenderer.invoke("openpets:plugin-platform-ai-key-status"),
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"),
getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"),
setDefaultPet: (petId) => ipcRenderer.invoke("openpets:set-default-pet", petId),
installPet: (petId) => ipcRenderer.invoke("openpets:install-pet", petId),
installLocalPet: () => ipcRenderer.invoke("openpets:install-local-pet"),
importCodexPet: (petId) => ipcRenderer.invoke("openpets:import-codex-pet", petId),
openGallery: () => ipcRenderer.invoke("openpets:open-gallery"),
removePet: (petId) => ipcRenderer.invoke("openpets:remove-pet", petId),
onRouteChange: (callback) => {
const listener = (_event, route) => callback(route);
ipcRenderer.on("familiaros:control-center-route", listener);
return () => ipcRenderer.removeListener("familiaros:control-center-route", listener);
ipcRenderer.on("openpets:control-center-route", listener);
return () => ipcRenderer.removeListener("openpets:control-center-route", listener);
},
onPluginsRefresh: (callback) => {
const listener = () => callback();
ipcRenderer.on("familiaros:plugins-refresh", listener);
return () => ipcRenderer.removeListener("familiaros:plugins-refresh", listener);
ipcRenderer.on("openpets:plugins-refresh", listener);
return () => ipcRenderer.removeListener("openpets:plugins-refresh", listener);
},
getIntegrationsState: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:agent-setup-snapshot", selectedPetId, commandMode),
runIntegrationAction: (action, selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:agent-setup-action", action, selectedPetId, commandMode),
updateIntegrationCommandPaths: (patch) => ipcRenderer.invoke("familiaros:agent-setup-command-paths", patch),
getFamiliarOSMcpServerPreview: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:familiaros-mcp-server-preview", selectedPetId, commandMode),
testFamiliarOSMcpServer: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:test-familiaros-mcp-server", selectedPetId, commandMode),
getTtsSettings: () => ipcRenderer.invoke("familiaros:get-tts-settings"),
getTtsVoices: (provider) => ipcRenderer.invoke("familiaros:get-tts-voices", provider),
saveTtsCredential: (provider, credential) => ipcRenderer.invoke("familiaros:save-tts-credential", provider, credential),
clearTtsCredential: (provider) => ipcRenderer.invoke("familiaros:clear-tts-credential", provider),
testTtsSpeak: (text) => ipcRenderer.invoke("familiaros:test-tts", text),
stopTts: () => ipcRenderer.invoke("familiaros:tts-stop"),
copyText: (text) => ipcRenderer.invoke("familiaros:copy-text", text),
openExternalUrl: (url) => ipcRenderer.invoke("familiaros:open-external-url", url),
installMcpToolkit: (target) => ipcRenderer.invoke("familiaros:install-mcp-toolkit", target),
getVanillaChatMcpTools: () => ipcRenderer.invoke("familiaros:get-vanilla-chat-mcp-tools"),
setVanillaChatMcpTools: (toolIds) => ipcRenderer.invoke("familiaros:set-vanilla-chat-mcp-tools", toolIds),
getMemories: (query, limit) => ipcRenderer.invoke("familiaros:get-memories", query, limit),
storeMemory: (text, kind, tags, importance) => ipcRenderer.invoke("familiaros:store-memory", text, kind, tags, importance),
updateMemory: (id, text, kind, tags, importance) => ipcRenderer.invoke("familiaros:update-memory", id, text, kind, tags, importance),
deleteMemory: (id) => ipcRenderer.invoke("familiaros:delete-memory", id),
knowledgeList: (limit) => ipcRenderer.invoke("familiaros:knowledge-list", limit),
knowledgeSearch: (query, limit) => ipcRenderer.invoke("familiaros:knowledge-search", query, limit),
knowledgeDeleteFile: (id) => ipcRenderer.invoke("familiaros:knowledge-delete-file", id),
knowledgeAddMemory: (text, kind, tags, importance) => ipcRenderer.invoke("familiaros:knowledge-add-memory", text, kind, tags, importance),
knowledgeStoreFile: () => ipcRenderer.invoke("familiaros:knowledge-store-file"),
getIntegrationsState: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-snapshot", selectedPetId, commandMode),
runIntegrationAction: (action, selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-action", action, selectedPetId, commandMode),
updateIntegrationCommandPaths: (patch) => ipcRenderer.invoke("openpets:agent-setup-command-paths", patch),
copyText: (text) => ipcRenderer.invoke("openpets:copy-text", text),
openExternalUrl: (url) => ipcRenderer.invoke("openpets:open-external-url", url),
installMcpToolkit: (target) => ipcRenderer.invoke("openpets:install-mcp-toolkit", target),
getVanillaChatMcpTools: () => ipcRenderer.invoke("openpets:get-vanilla-chat-mcp-tools"),
setVanillaChatMcpTools: (toolIds) => ipcRenderer.invoke("openpets:set-vanilla-chat-mcp-tools", toolIds),
getMemories: (query, limit) => ipcRenderer.invoke("openpets:get-memories", query, limit),
storeMemory: (text, kind, tags, importance) => ipcRenderer.invoke("openpets:store-memory", text, kind, tags, importance),
updateMemory: (id, text, kind, tags, importance) => ipcRenderer.invoke("openpets:update-memory", id, text, kind, tags, importance),
deleteMemory: (id) => ipcRenderer.invoke("openpets:delete-memory", id),
};
contextBridge.exposeInMainWorld("familiarOSControlCenter", api);
contextBridge.exposeInMainWorld("openPetsControlCenter", api);

View file

@ -1,17 +1,17 @@
appId: dev.familiaros.app
productName: FamiliarOS
executableName: familiaros
copyright: Copyright © 2026 FamiliarOS
appId: dev.openpets.app
productName: OpenPets
executableName: openpets
copyright: Copyright © 2026 OpenPets
directories:
output: dist-electron
artifactName: FamiliarOS-${version}-${os}-${arch}.${ext}
artifactName: OpenPets-${version}-${os}-${arch}.${ext}
files:
- dist/**
- control-center-preload.cjs
- familiar-preload.cjs
- pet-preload.cjs
- prompt-window-preload.cjs
- plugin-sdk-preload.cjs
- plugin-command-form-preload.cjs
@ -26,12 +26,6 @@ extraResources:
asar: true
asarUnpack:
- node_modules/**
- control-center-preload.cjs
- familiar-preload.cjs
- prompt-window-preload.cjs
- plugin-sdk-preload.cjs
- plugin-command-form-preload.cjs
- panel-preload.cjs
npmRebuild: false
publish: null
@ -59,13 +53,13 @@ win:
verifyUpdateCodeSignature: false
nsis:
artifactName: FamiliarOS-${version}-win-${arch}-setup.${ext}
artifactName: OpenPets-${version}-win-${arch}-setup.${ext}
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
portable:
artifactName: FamiliarOS-${version}-win-${arch}-portable.${ext}
artifactName: OpenPets-${version}-win-${arch}-portable.${ext}
linux:
icon: assets/app-icon.png
@ -75,4 +69,4 @@ linux:
- rpm
- tar.gz
category: Utility
maintainer: FamiliarOS
maintainer: OpenPets

View file

@ -1,23 +1,23 @@
{
"name": "@familiaros/desktop",
"name": "@open-pets/desktop",
"version": "3.1.0",
"private": true,
"description": "Create your Familiar. A local-first AI companion with memory, voice, knowledge, and tools.",
"description": "OpenPets tray-first desktop companion app.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/verticaltension/familiaros.git",
"url": "git+https://github.com/alvinunreal/openpets.git",
"directory": "apps/desktop"
},
"author": "FamiliarOS",
"author": "OpenPets",
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "pnpm build && electron .",
"dev:electron": "pnpm build:main && electron .",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env FAMILIAROS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "cross-env FAMILIAROS_DISABLE_PLUGIN_CATALOG=1 FAMILIAROS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev",
"dev:debug": "FAMILIAROS_LOG_LEVEL=debug FAMILIAROS_LOG_CONSOLE=1 pnpm dev",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env OPENPETS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "cross-env OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev",
"dev:debug": "OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev",
"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 scripts/run-tests.mjs",
@ -31,7 +31,7 @@
"build": "pnpm build:main && pnpm build:renderer"
},
"devDependencies": {
"@familiaros/plugin-sdk": "workspace:*",
"@open-pets/plugin-sdk": "workspace:*",
"@types/node": "^25.6.2",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@ -50,12 +50,12 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@familiaros/agent-events": "workspace:*",
"@familiaros/claude": "workspace:*",
"@familiaros/cli": "workspace:*",
"@familiaros/cursor": "workspace:*",
"@familiaros/mcp": "workspace:*",
"@familiaros/opencode": "workspace:*",
"@open-pets/agent-events": "workspace:*",
"@open-pets/claude": "workspace:*",
"@open-pets/cli": "workspace:*",
"@open-pets/cursor": "workspace:*",
"@open-pets/mcp": "workspace:*",
"@open-pets/opencode": "workspace:*",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sharp": "^0.34.5",

View file

@ -1,7 +1,7 @@
const { contextBridge, ipcRenderer } = require("electron");
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-panel-token="));
const channel = tokenArg ? `familiaros:plugin-panel:${tokenArg.slice("--familiaros-panel-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--openpets-panel-token="));
const channel = tokenArg ? `openpets:plugin-panel:${tokenArg.slice("--openpets-panel-token=".length)}` : "";
const handlers = new Set();
ipcRenderer.on(`${channel}:message`, (_event, msg) => {
@ -10,11 +10,8 @@ ipcRenderer.on(`${channel}:message`, (_event, msg) => {
}
});
const api = {
contextBridge.exposeInMainWorld("openPetsPanel", {
postMessage: (msg) => { if (channel) ipcRenderer.send(`${channel}:to-plugin`, msg); },
onMessage: (handler) => { if (typeof handler === "function") handlers.add(handler); return () => handlers.delete(handler); },
close: () => { if (channel) ipcRenderer.send(`${channel}:close`); },
};
contextBridge.exposeInMainWorld("familiarOSPanel", api);
contextBridge.exposeInMainWorld("openPetsPanel", api);
});

View file

@ -8,7 +8,6 @@ let scaling = false;
let scaleStartY = 0;
let scaleStartValue = 0.56;
let scalePreviewTimer = null;
const dragThreshold = 4;
const dismissBubble = (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
@ -28,23 +27,23 @@ const dismissBubble = (event) => {
bubble.remove();
const newTarget = document.elementFromPoint(event.clientX, event.clientY);
const stillInteractive = Boolean(newTarget && newTarget.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling;
const stillInteractive = Boolean(newTarget && newTarget.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging || scaling;
reportInteractiveHit(stillInteractive, "bubble-dismiss", true);
ipcRenderer.send("familiaros:bubble-dismissed", dismissToken);
ipcRenderer.send("openpets:bubble-dismissed", dismissToken);
};
const requestPromptWindow = (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".bubble")) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
if (!target.closest(".pet-hitbox, .pet-shell")) return;
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("familiaros:familiar-open-prompt");
ipcRenderer.send("openpets:pet-open-prompt");
};
ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
ipcRenderer.on("openpets:pet-motion", (_event, state) => {
if (!allowedMotionStates.has(state)) {
return;
}
@ -60,7 +59,7 @@ ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
}
});
ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
if (!allowedReactionStates.has(state)) {
return;
}
@ -76,7 +75,7 @@ ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
}
});
ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
ipcRenderer.on("openpets:pet-content-state", (_event, state) => {
if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) {
return;
}
@ -95,13 +94,13 @@ ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
const getInteractiveTarget = (event) => {
const target = document.elementFromPoint(event.clientX, event.clientY);
return target && target.closest(".familiar-hitbox, .familiar-shell, .bubble");
return target && target.closest(".pet-hitbox, .pet-shell, .bubble");
};
const reportInteractiveHit = (interactive, source, force = false) => {
if (!force && lastInteractiveHit === interactive) return;
lastInteractiveHit = interactive;
ipcRenderer.send("familiaros:familiar-hit-test", interactive, source);
ipcRenderer.send("openpets:pet-hit-test", interactive, source);
};
const setInteractiveHit = (interactive, source = "mouse") => {
@ -129,15 +128,19 @@ const applyScalePreview = (scale) => {
};
const sendScalePreview = (scale) => {
ipcRenderer.send("familiaros:familiar-scale-preview", { scale });
if (scalePreviewTimer) return;
scalePreviewTimer = setTimeout(() => {
scalePreviewTimer = null;
}, 120);
ipcRenderer.send("openpets:pet-scale-preview", { scale });
};
ipcRenderer.on("familiaros:familiar-probe-hit-test", (_event, point) => {
ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => {
if (!point || typeof point.clientX !== "number" || typeof point.clientY !== "number" || !Number.isFinite(point.clientX) || !Number.isFinite(point.clientY)) return;
const clientX = point.clientX;
const clientY = point.clientY;
const target = document.elementFromPoint(clientX, clientY);
reportInteractiveHit(Boolean(target && target.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
reportInteractiveHit(Boolean(target && target.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
});
// --- Plugin bubble interactions (actions, inline inputs) -------------------
@ -159,7 +162,7 @@ const handleBubbleInteraction = (event) => {
if (actionButton) {
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("familiaros:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
ipcRenderer.send("openpets:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
return true;
}
const submitButton = target.closest("[data-bubble-submit]");
@ -167,20 +170,20 @@ const handleBubbleInteraction = (event) => {
event.preventDefault();
event.stopPropagation();
const bubble = submitButton.closest(".bubble");
ipcRenderer.send("familiaros:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
ipcRenderer.send("openpets:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
return true;
}
if (target.closest(".bubble-input-control")) return true;
return false;
};
// --- Familiar senses: clicks, hover, drops ---------------------------------------
// --- Pet senses: clicks, hover, drops ---------------------------------------
let lastHoverSentAt = 0;
let suppressClickUntil = 0;
const sendPetEvent = (name, payload) => {
ipcRenderer.send("familiaros:familiar-event", name, payload || {});
ipcRenderer.send("openpets:pet-event", name, payload || {});
};
const installPetSenses = () => {
@ -198,9 +201,9 @@ const installPetSenses = () => {
const flushClicks = () => {
resetClickTimer();
if (clickCount === 1) {
sendPetEvent("familiar:clicked", {});
sendPetEvent("pet:clicked", {});
} else if (clickCount === 2 && firstClickEvent) {
sendPetEvent("familiar:doubleClicked", {});
sendPetEvent("pet:doubleClicked", {});
requestPromptWindow(firstClickEvent);
}
clickCount = 0;
@ -211,7 +214,7 @@ const installPetSenses = () => {
if (event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
if (!target.closest(".pet-hitbox, .pet-shell")) return;
if (Date.now() < suppressClickUntil) return;
clickCount += 1;
@ -222,25 +225,24 @@ const installPetSenses = () => {
clickTimer = setTimeout(flushClicks, 200);
});
document.addEventListener("mouseover", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
const now = Date.now();
if (now - lastHoverSentAt < 2000) return;
lastHoverSentAt = now;
sendPetEvent("familiar:hover", {});
sendPetEvent("pet:hover", {});
}, { passive: true });
const maxDropTextBytes = 256 * 1024;
const maxDropFileBytes = 5 * 1024 * 1024;
document.addEventListener("dragover", (event) => {
const target = event.target;
if (target instanceof Element && target.closest(".familiar-hitbox, .familiar-shell")) event.preventDefault();
if (target instanceof Element && target.closest(".pet-hitbox, .pet-shell")) event.preventDefault();
});
document.addEventListener("drop", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) return;
@ -251,19 +253,19 @@ const installPetSenses = () => {
sizeBytes: file.size,
text: file.size <= maxDropFileBytes ? await file.text().catch(() => "") : "",
truncated: file.size > maxDropFileBytes,
}))).then((read) => sendPetEvent("familiar:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
}))).then((read) => sendPetEvent("pet:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
return;
}
const text = String(transfer.getData("text/plain") || "").slice(0, maxDropTextBytes);
if (text) sendPetEvent("familiar:drop", { kind: "text", text });
if (text) sendPetEvent("pet:drop", { kind: "text", text });
});
};
// --- Plugin sprite/scale overrides ------------------------------------------
let spriteOverrideElement = null;
ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
const shell = document.querySelector(".familiar-shell");
ipcRenderer.on("openpets:pet-sprite-override", (_event, override) => {
const shell = document.querySelector(".pet-shell");
if (!shell) return;
const base = shell.querySelector(".sprite, .installed-card");
if (spriteOverrideElement) { spriteOverrideElement.remove(); spriteOverrideElement = null; }
@ -293,14 +295,14 @@ ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
probe.src = override.fileUrl;
});
ipcRenderer.on("familiaros:familiar-scale-override", (_event, scale) => {
ipcRenderer.on("openpets:pet-scale-override", (_event, scale) => {
const value = Number(scale);
if (!Number.isFinite(value) || value < 0.25 || value > 3) return;
const sprite = document.querySelector(".sprite, .installed-sprite");
if (sprite) sprite.style.transform = `scale(${value})`;
});
ipcRenderer.on("familiaros:familiar-bubble-layout", (_event, layout) => {
ipcRenderer.on("openpets:pet-bubble-layout", (_event, layout) => {
if (!layout || typeof layout !== "object") return;
const root = document.documentElement;
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
@ -320,7 +322,7 @@ let activeAudioElements = [];
const audioLog = (level, message, fields) => {
try {
const safeFields = fields && Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
const line = `[familiaros:familiar-audio] ${message}`;
const line = `[openpets:pet-audio] ${message}`;
if (level === "warn") console.warn(line, safeFields || {});
else console.debug(line, safeFields || {});
} catch { /* diagnostics must never affect playback */ }
@ -342,7 +344,7 @@ const namedSoundRecipes = {
error: [{ freq: 311.13, type: "sine", start: 0, duration: 0.18 }, { freq: 233.08, type: "sine", start: 0.2, duration: 0.28 }],
};
ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
ipcRenderer.on("openpets:play-audio", (_event, payload) => {
try {
if (!payload) return;
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
@ -385,7 +387,7 @@ ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
});
ipcRenderer.on("familiaros:stop-audio", () => {
ipcRenderer.on("openpets:stop-audio", () => {
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
activeAudioNodes = [];
@ -395,11 +397,8 @@ ipcRenderer.on("familiaros:stop-audio", () => {
// --- Plugin TTS ---------------------------------------------------------------
let currentTtsAudio = null;
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
@ -411,37 +410,10 @@ ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
const blob = new Blob([buffer], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
currentTtsAudio = new Audio(url);
currentTtsAudio.addEventListener("ended", () => {
URL.revokeObjectURL(url);
currentTtsAudio = null;
});
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
} catch { /* tts is best-effort */ }
ipcRenderer.on("openpets:tts-stop", () => {
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch { /* noop */ }
});
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} catch { /* noop */ }
});
function stopCurrentTtsAudio() {
if (currentTtsAudio) {
try { currentTtsAudio.pause(); } catch { /* noop */ }
try { currentTtsAudio.src = ""; } catch { /* noop */ }
currentTtsAudio = null;
}
}
const installMouseInterop = () => {
lastInteractiveHit = null;
dragging = false;
@ -454,25 +426,12 @@ const installMouseInterop = () => {
installPetSenses();
let dragStartPoint = null;
let dragCandidate = null;
document.addEventListener("mousemove", (event) => {
updateInteractiveHit(event);
if (dragging) {
ipcRenderer.send("familiaros:familiar-drag-move", { screenX: event.screenX, screenY: event.screenY });
} else if (dragCandidate) {
const dx = event.screenX - dragCandidate.screenX;
const dy = event.screenY - dragCandidate.screenY;
if (Math.hypot(dx, dy) > dragThreshold) {
dragging = true;
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
dragCandidate = null;
setInteractiveHit(true);
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
}
}
if (dragging) ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY });
if (scaling) {
const newScale = scaleStartValue + (event.screenY - scaleStartY) * 0.003;
const newScale = scaleStartValue + (event.clientY - scaleStartY) * 0.003;
const clamped = applyScalePreview(newScale);
sendScalePreview(clamped);
}
@ -487,36 +446,35 @@ const installMouseInterop = () => {
if (event.button !== 0) return;
event.preventDefault();
scaling = true;
scaleStartY = event.screenY;
scaleStartY = event.clientY;
scaleStartValue = getCurrentSpriteScale();
setInteractiveHit(true);
ipcRenderer.send("familiaros:familiar-scale-start");
ipcRenderer.send("openpets:pet-scale-start");
return;
}
if (event.button !== 0 || !target?.closest(".familiar-hitbox, .familiar-shell")) return;
if (event.button !== 0 || !target?.closest(".pet-hitbox, .pet-shell")) return;
event.preventDefault();
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
dragStartPoint = null;
dragging = true;
dragStartPoint = { screenX: event.screenX, screenY: event.screenY };
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY });
});
document.addEventListener("mouseup", (event) => {
if (dragging) {
dragging = false;
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > dragThreshold) {
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > 4) {
suppressClickUntil = Date.now() + 300;
}
dragStartPoint = null;
dragCandidate = null;
ipcRenderer.send("familiaros:familiar-drag-end");
} else if (dragCandidate) {
dragCandidate = null;
ipcRenderer.send("openpets:pet-drag-end");
}
if (scaling) {
scaling = false;
const sprite = document.querySelector(".sprite, .installed-sprite");
const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue;
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale });
}
});
@ -524,33 +482,8 @@ const installMouseInterop = () => {
if (!dragging && !scaling) setInteractiveHit(false);
}, { passive: true });
const scaleNudgeStep = 0.02;
let scaleNudgeCommitTimer = null;
const commitScaleNudge = () => {
if (scaleNudgeCommitTimer) {
clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = null;
}
const finalScale = getCurrentSpriteScale();
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
};
window.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const current = getCurrentSpriteScale();
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
const next = applyScalePreview(current + delta);
ipcRenderer.send("familiaros:familiar-scale-preview", { scale: next });
if (scaleNudgeCommitTimer) clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = setTimeout(commitScaleNudge, 250);
});
setInteractiveHit(false, "ready");
ipcRenderer.send("familiaros:familiar-ready");
ipcRenderer.send("openpets:pet-ready");
};
if (document.readyState === "loading") {

View file

@ -1,10 +1,7 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
contextBridge.exposeInMainWorld("openPetsCommandForm", {
submit: (channel, values) => ipcRenderer.invoke(String(channel), values && typeof values === "object" ? values : {}),
resize: (channel, size) => ipcRenderer.send(String(channel), size && typeof size === "object" ? size : {}),
close: () => window.close(),
};
contextBridge.exposeInMainWorld("familiarOSCommandForm", api);
contextBridge.exposeInMainWorld("openPetsCommandForm", api);
});

View file

@ -4,27 +4,20 @@ const { contextBridge, ipcRenderer } = require("electron");
// conformance check extracts call()/callSync()/subscription() route literals
// from this preload and compares them to the canonical route table.
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-plugin-token="));
const channel = tokenArg ? `familiaros:plugin-sdk:${tokenArg.slice("--familiaros-plugin-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--openpets-plugin-token="));
const channel = tokenArg ? `openpets:plugin-sdk:${tokenArg.slice("--openpets-plugin-token=".length)}` : "";
let callbackId = 0;
const callbacks = new Map();
async function call(path, args) {
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
return ipcRenderer.invoke(channel, path, normalizeForIpc(args));
}
function callSync(path, args) {
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
const result = ipcRenderer.sendSync(channel, path, normalizeForIpc(args));
if (result && typeof result === "object") {
const errorMessage = typeof result.__familiarOSError === "string"
? result.__familiarOSError
: typeof result.__openPetsError === "string"
? result.__openPetsError
: "";
if (errorMessage) throw new Error(errorMessage);
}
if (result && typeof result === "object" && typeof result.__openPetsError === "string") throw new Error(result.__openPetsError);
return result;
}
@ -104,7 +97,7 @@ function wrapPickedFile(file) {
}
function wrapEventPayload(event, payload) {
if (event !== "familiar:drop" || !payload || !Array.isArray(payload.files)) return payload;
if (event !== "pet:drop" || !payload || !Array.isArray(payload.files)) return payload;
return {
...payload,
files: payload.files.map((file) => ({
@ -118,35 +111,35 @@ function wrapEventPayload(event, payload) {
function makePetHandle(petId) {
return {
id: String(petId),
speak: (spec) => call("familiar.speak", [petId, spec]).then(makeBubbleHandle),
react: (reaction, options) => call("familiar.react", [petId, reaction, options]),
setAnimation: (state) => call("familiar.setAnimation", [petId, state]),
setScale: (scale) => call("familiar.setScale", [petId, scale]),
setStatusReaction: (reaction) => call("familiar.setStatusReaction", [petId, reaction]),
moveBy: (options) => call("familiar.moveBy", [petId, options]),
wander: (options) => call("familiar.wander", [petId, options]),
moveToHome: () => call("familiar.moveToHome", [petId]),
moveTo: (point, opts) => call("familiar.moveTo", [petId, point, opts]),
followCursor: (opts) => call("familiar.followCursor", [petId, opts]),
physics: (opts) => call("familiar.physics", [petId, opts]),
onTick: (fn) => subscription("familiar.onTick", "familiar.offTick", [petId], fn),
getState: () => call("familiar.getState", [petId]),
show: () => call("familiar.show", [petId]),
hide: () => call("familiar.hide", [petId]),
close: () => call("familiar.close", [petId]),
speak: (spec) => call("pet.speak", [petId, spec]).then(makeBubbleHandle),
react: (reaction, options) => call("pet.react", [petId, reaction, options]),
setAnimation: (state) => call("pet.setAnimation", [petId, state]),
setScale: (scale) => call("pet.setScale", [petId, scale]),
setStatusReaction: (reaction) => call("pet.setStatusReaction", [petId, reaction]),
moveBy: (options) => call("pet.moveBy", [petId, options]),
wander: (options) => call("pet.wander", [petId, options]),
moveToHome: () => call("pet.moveToHome", [petId]),
moveTo: (point, opts) => call("pet.moveTo", [petId, point, opts]),
followCursor: (opts) => call("pet.followCursor", [petId, opts]),
physics: (opts) => call("pet.physics", [petId, opts]),
onTick: (fn) => subscription("pet.onTick", "pet.offTick", [petId], fn),
getState: () => call("pet.getState", [petId]),
show: () => call("pet.show", [petId]),
hide: () => call("pet.hide", [petId]),
close: () => call("pet.close", [petId]),
};
}
const defaultPet = makePetHandle("default");
const sdk = {
familiar: defaultPet,
familiars: {
pet: defaultPet,
pets: {
default: defaultPet,
list: () => call("familiars.list", []),
list: () => call("pets.list", []),
get: (petId) => makePetHandle(petId),
spawn: (spec) => call("familiars.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("familiars.onChange", "familiars.offChange", [], fn),
spawn: (spec) => call("pets.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("pets.onChange", "pets.offChange", [], fn),
},
ui: {
bubble: (spec) => call("ui.bubble", [spec]).then(makeBubbleHandle),
@ -265,13 +258,9 @@ Object.defineProperty(sdk, "locale", {
get: () => callSync("i18n.locale", []),
});
contextBridge.exposeInMainWorld("__familiarOSSdk", sdk);
contextBridge.exposeInMainWorld("__openPetsSdk", sdk);
const runCallback = async (id, args) => {
contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => {
const callback = callbacks.get(id);
if (callback) return callback(...(Array.isArray(args) ? args : []));
return undefined;
};
contextBridge.exposeInMainWorld("__familiarOSRunCallback", runCallback);
contextBridge.exposeInMainWorld("__openPetsRunCallback", runCallback);
});

View file

@ -1,62 +1,15 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
getState: () => ipcRenderer.invoke("familiaros:prompt-window-state"),
submitPrompt: (prompt) => ipcRenderer.invoke("familiaros:prompt-window-submit", prompt),
resetConversation: () => ipcRenderer.invoke("familiaros:prompt-window-reset-conversation"),
createConversation: () => ipcRenderer.invoke("familiaros:prompt-window-create-conversation"),
switchConversation: (conversationId) => ipcRenderer.invoke("familiaros:prompt-window-switch-conversation", conversationId),
deleteConversation: (conversationId) => ipcRenderer.invoke("familiaros:prompt-window-delete-conversation", conversationId),
openSettings: () => ipcRenderer.invoke("familiaros:prompt-window-open-settings"),
resizeWindow: (bounds) => ipcRenderer.invoke("familiaros:prompt-window-resize", bounds),
close: () => ipcRenderer.invoke("familiaros:prompt-window-close"),
storeKnowledgeFile: (file) => ipcRenderer.invoke("familiaros:prompt-window-store-file", file),
getState: () => ipcRenderer.invoke("openpets:prompt-window-state"),
submitPrompt: (prompt) => ipcRenderer.invoke("openpets:prompt-window-submit", prompt),
resetConversation: () => ipcRenderer.invoke("openpets:prompt-window-reset-conversation"),
createConversation: () => ipcRenderer.invoke("openpets:prompt-window-create-conversation"),
switchConversation: (conversationId) => ipcRenderer.invoke("openpets:prompt-window-switch-conversation", conversationId),
deleteConversation: (conversationId) => ipcRenderer.invoke("openpets:prompt-window-delete-conversation", conversationId),
openSettings: () => ipcRenderer.invoke("openpets:prompt-window-open-settings"),
resizeWindow: (bounds) => ipcRenderer.invoke("openpets:prompt-window-resize", bounds),
close: () => ipcRenderer.invoke("openpets:prompt-window-close"),
};
contextBridge.exposeInMainWorld("familiarOSPromptWindow", api);
contextBridge.exposeInMainWorld("openPetsPromptWindow", api);
// --- TTS playback -------------------------------------------------------------
let currentTtsAudio = null;
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
window.speechSynthesis.speak(utterance);
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
const blob = new Blob([buffer], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
currentTtsAudio = new Audio(url);
currentTtsAudio.addEventListener("ended", () => {
URL.revokeObjectURL(url);
currentTtsAudio = null;
});
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} catch { /* noop */ }
});
function stopCurrentTtsAudio() {
if (currentTtsAudio) {
try { currentTtsAudio.pause(); } catch { /* noop */ }
try { currentTtsAudio.src = ""; } catch { /* noop */ }
currentTtsAudio = null;
}
}

View file

@ -11,16 +11,16 @@ import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const filters = [
"@familiaros/agent-events",
"@familiaros/client",
"@familiaros/claude",
"@familiaros/cli",
"@familiaros/cursor",
"@familiaros/mcp",
"@familiaros/opencode",
"@familiaros/familiar-format",
"@familiaros/pi",
"@familiaros/plugin-sdk",
"@open-pets/agent-events",
"@open-pets/client",
"@open-pets/claude",
"@open-pets/cli",
"@open-pets/cursor",
"@open-pets/mcp",
"@open-pets/opencode",
"@open-pets/pet-format",
"@open-pets/pi",
"@open-pets/plugin-sdk",
];
const args = [];

View file

@ -2,7 +2,7 @@
## Responsibility
Build and release automation scripts for the FamiliarOS desktop application. Handles packaging cleanup and local release orchestration (macOS-focused).
Build and release automation scripts for the OpenPets desktop application. Handles packaging cleanup and local release orchestration (macOS-focused).
## Design
@ -37,7 +37,7 @@ Check preload syntax → Compile tests to .test-dist → Run behavior tests →
- **File System**: `apps/desktop/dist-electron/` (build output), `apps/desktop/dist/` (compiled JS)
- **Git**: Working tree status, remote sync verification, tag existence checks
- **GitHub**: `gh release create`, `gh release upload` to `alvinunreal/familiaros`
- **GitHub**: `gh release create`, `gh release upload` to `alvinunreal/openpets`
- **Build Tools**: `pnpm`, `electron-builder`, `node --check`
- **Node APIs**: `crypto` (SHA256), `fs`, `path`, `child_process.spawnSync`

View file

@ -9,7 +9,7 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url));
const desktopDir = resolve(scriptsDir, "..");
const repoRoot = resolve(desktopDir, "../..");
const outputDir = join(desktopDir, "dist-electron");
const repository = "alvinunreal/familiaros";
const repository = "alvinunreal/openpets";
const allowedArgs = new Set([
"--dry-run",
@ -55,7 +55,7 @@ function main() {
preflight();
if (!skipChecks) {
run("pnpm", ["build"], { cwd: repoRoot });
run("pnpm", ["--filter", "@familiaros/desktop", "check"], { cwd: repoRoot });
run("pnpm", ["--filter", "@open-pets/desktop", "check"], { cwd: repoRoot });
}
run("node", ["scripts/clean-package-output.cjs"], { cwd: desktopDir });
@ -85,7 +85,7 @@ function main() {
}
const target = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim();
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--title", `FamiliarOS ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--title", `OpenPets ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "upload", tag, "--repo", repository, ...uploadArtifacts], { cwd: repoRoot });
console.log(`\nPublished release created: https://github.com/${repository}/releases/tag/${tag}`);
console.log("Published releases are visible to the app update checker.");
@ -208,18 +208,18 @@ function run(command, args, options) {
function defaultReleaseNotes() {
return [
`FamiliarOS ${tag} ships the first official plugin release with polished desktop plugin management.`,
`OpenPets ${tag} ships the first official plugin release with polished desktop plugin management.`,
"",
"## New: FamiliarOS Plugins",
"## New: OpenPets Plugins",
"",
"FamiliarOS now includes a first-party plugin platform for bundled desktop companion behaviors.",
"OpenPets now includes a first-party plugin platform for bundled desktop companion behaviors.",
"",
"## Included plugins",
"",
"- Ambient Companion — gentle idle check-ins and ambient familiar moments, enabled by default.",
"- Ambient Companion — gentle idle check-ins and ambient pet moments, enabled by default.",
"- Break Buddy — friendly stretch, hydrate, and rest reminders, enabled by default.",
"- Familiar Pal — playful familiar interactions and quick companion actions, enabled by default.",
"- Focus Buddy — passive focus-session controls available from the familiar menu, enabled by default.",
"- Pet Pal — playful pet interactions and quick companion actions, enabled by default.",
"- Focus Buddy — passive focus-session controls available from the pet menu, enabled by default.",
"- GitHub Notifications — developer/advanced public repository release and failed-workflow notifications, disabled by default. No GitHub login, token, or private repository access is used.",
"",
"## Plugin management",
@ -227,7 +227,7 @@ function defaultReleaseNotes() {
"- New polished Plugins window with install, enable, configure, update, reload, and uninstall actions.",
"- Friendly plugin configuration UI; no JSON editing required.",
"- Plugin permissions, network hosts, and official plugin icons are explicit.",
"- JavaScript plugins run in a sandboxed renderer with a narrow FamiliarOS SDK.",
"- JavaScript plugins run in a sandboxed renderer with a narrow OpenPets SDK.",
"",
"## Developer notes",
"",

View file

@ -11,116 +11,25 @@ import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const preloadChecks = [
"control-center-preload.cjs",
"familiar-preload.cjs",
"plugin-sdk-preload.cjs",
"panel-preload.cjs",
"plugin-command-form-preload.cjs",
"prompt-window-preload.cjs",
];
const preloadChecks = ["control-center-preload.cjs", "pet-preload.cjs", "plugin-sdk-preload.cjs", "panel-preload.cjs"];
const behaviorTests = [
".test-dist/tests/lease-manager.test.js",
".test-dist/tests/custom-familiar-name.test.js",
".test-dist/tests/internal-ui-route-conformance.test.js",
".test-dist/tests/control-center-ipc-route-seams.test.js",
".test-dist/tests/familiar-window-render.test.js",
".test-dist/tests/familiar-window-content.test.js",
".test-dist/tests/familiar-window-layout.test.js",
".test-dist/tests/familiar-window-renderer-bridge.test.js",
".test-dist/tests/local-ipc-socket.test.js",
".test-dist/tests/prompt-window-render.test.js",
".test-dist/tests/prompt-window-render-seams.test.js",
".test-dist/tests/prompt-window-render-script-sections.test.js",
".test-dist/tests/familiar-window-interactions.test.js",
".test-dist/tests/familiar-window-mouse-interop-runtime.test.js",
".test-dist/tests/familiar-window-plugin-menu.test.js",
".test-dist/tests/settings-view-tabs.test.js",
".test-dist/tests/settings-view-state.test.js",
".test-dist/tests/settings-view-action-seams.test.js",
".test-dist/tests/settings-view-state-actions-split.test.js",
".test-dist/tests/control-center-state-validation.test.js",
".test-dist/tests/settings-openapi-group-provider.test.js",
".test-dist/tests/settings-tts-group-split.test.js",
".test-dist/tests/default-familiar-external-show.test.js",
".test-dist/tests/default-familiar-controller-bubbles.test.js",
".test-dist/tests/default-familiar-controller-motion.test.js",
".test-dist/tests/default-familiar-controller-display-support.test.js",
".test-dist/tests/default-familiar-controller-window.test.js",
".test-dist/tests/app-state-preferences.test.js",
".test-dist/tests/app-state-analytics.test.js",
".test-dist/tests/app-state-familiar-records.test.js",
".test-dist/tests/app-state-normalization.test.js",
".test-dist/tests/app-state-storage.test.js",
".test-dist/tests/default-pet-external-show.test.js",
".test-dist/tests/onboarding-state.test.js",
".test-dist/tests/update-version.test.js",
".test-dist/tests/reaction-animation-mapping.test.js",
".test-dist/tests/openapi-chat-settings.test.js",
".test-dist/tests/openapi-chat-prompt-flows.test.js",
".test-dist/tests/openapi-chat-prompt-plain-flow.test.js",
".test-dist/tests/openapi-chat-provider.test.js",
".test-dist/tests/openapi-chat-tool-loop.test.js",
".test-dist/tests/openapi-chat-request-helpers.test.js",
".test-dist/tests/openapi-chat-conversation-store.test.js",
".test-dist/tests/openapi-chat-config-store.test.js",
".test-dist/tests/catalog-surfaceable.test.js",
".test-dist/tests/zip-safety.test.js",
".test-dist/tests/tts-engine.test.js",
".test-dist/tests/codex-familiars.test.js",
".test-dist/tests/catalog-remote-seams.test.js",
".test-dist/tests/codex-pets.test.js",
".test-dist/tests/claude-memory.test.js",
".test-dist/tests/prompt-memory-extraction.test.js",
".test-dist/tests/familiaros-memory-search.test.js",
".test-dist/tests/knowledge-store-core-seam.test.js",
".test-dist/tests/knowledge-store.test.js",
".test-dist/tests/control-center-service-barrels.test.js",
".test-dist/tests/mcp-toolkit-installer-seams.test.js",
".test-dist/tests/mcp-toolkit-catalog.test.js",
".test-dist/tests/integrations-view-toolkit-split.test.js",
".test-dist/tests/integrations-view-agent-sections.test.js",
".test-dist/tests/integrations-view-detail-dialog.test.js",
".test-dist/tests/integrations-view-state.test.js",
".test-dist/tests/familiars-view-detail-dialog.test.js",
".test-dist/tests/familiars-view-state.test.js",
".test-dist/tests/agent-setup-actions.test.js",
".test-dist/tests/agent-setup-actions-tooling.test.js",
".test-dist/tests/agent-setup-claude-status.test.js",
".test-dist/tests/agent-setup-command-runner.test.js",
".test-dist/tests/agent-setup-command-context.test.js",
".test-dist/tests/agent-setup-support.test.js",
".test-dist/tests/plugin-config.test.js",
".test-dist/tests/plugin-sdk-bridge.test.js",
".test-dist/tests/plugin-sdk-api-builder.test.js",
".test-dist/tests/plugin-sdk-api-builder-platform.test.js",
".test-dist/tests/plugin-sdk-api-builder-platform-files.test.js",
".test-dist/tests/plugin-sdk-bridge-runtime-state.test.js",
".test-dist/tests/plugin-sdk-bubble-validation.test.js",
".test-dist/tests/plugin-sdk-command-validation.test.js",
".test-dist/tests/plugin-sdk-runtime-helpers.test.js",
".test-dist/tests/plugin-sdk-storage-stores.test.js",
".test-dist/tests/plugin-sdk-surface-boundaries.test.js",
".test-dist/tests/plugin-state.test.js",
".test-dist/tests/plugin-runtime.test.js",
".test-dist/tests/plugin-catalog-validation.test.js",
".test-dist/tests/plugin-package.test.js",
".test-dist/tests/plugin-js-host-sdk-dispatch.test.js",
".test-dist/tests/plugin-service-app.test.js",
".test-dist/tests/plugin-service-actions.test.js",
".test-dist/tests/plugin-service.test.js",
".test-dist/tests/plugin-service-bundled-support.test.js",
".test-dist/tests/plugin-service-catalog-support.test.js",
".test-dist/tests/plugin-service-support.test.js",
".test-dist/tests/plugin-service-dialog-support.test.js",
".test-dist/tests/plugin-service-local-support.test.js",
".test-dist/tests/plugin-service-ui-helpers.test.js",
".test-dist/tests/plugin-manifest-validation-seams.test.js",
".test-dist/tests/plugin-manifest-shape-validation.test.js",
".test-dist/tests/plugins-view-config-list-editor.test.js",
".test-dist/tests/plugins-view-config-fields.test.js",
".test-dist/tests/plugin-ui-static.test.js",
".test-dist/tests/plugin-user-sound-store.test.js",
".test-dist/tests/plugin-bridge-fuzz.test.js",
".test-dist/tests/shared-ui-split.test.js",
];
const contractTests = [
".test-dist/contracts/local-ipc-protocol.contract.js",
@ -138,7 +47,7 @@ function run(command, args = [], options = {}) {
const child = spawn(command, args, {
stdio: "inherit",
cwd: rootDir,
env: { ...process.env, FAMILIAROS_DESKTOP_ROOT: rootDir },
env: { ...process.env, OPENPETS_DESKTOP_ROOT: rootDir },
...options,
});
child.on("close", (code) => {

View file

@ -3,8 +3,8 @@ import { BrowserWindow } from "electron";
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { debug, info } from "./logger.js";
import { transientDisplayMs, type FamiliarOSReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./familiar-window.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
const agentPetWindows = new Map<string, BrowserWindow>();
const transientDisplays = new Map<string, PetTransientDisplay>();
@ -18,11 +18,11 @@ const busyStatusBadgeMs = 120_000;
export function showAgentPet(petId: string): boolean {
if (dismissedAgentPets.has(petId)) {
info("familiar.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
info("pet.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
return false;
}
const window = getOrCreateAgentPetWindow(petId);
info("familiar.agent", "show requested", { petId, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), activeWindows: agentPetWindows.size });
info("pet.agent", "show requested", { petId, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), activeWindows: agentPetWindows.size });
if (window.isMinimized()) window.restore();
window.showInactive();
return true;
@ -31,10 +31,10 @@ export function showAgentPet(petId: string): boolean {
export function closeAgentPetIfOpen(petId: string): void {
const window = agentPetWindows.get(petId);
if (!window || window.isDestroyed()) {
debug("familiar.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
debug("pet.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
return;
}
info("familiar.agent", "close requested", { petId, windowId: window.id, activeWindows: agentPetWindows.size });
info("pet.agent", "close requested", { petId, windowId: window.id, activeWindows: agentPetWindows.size });
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
window.setIgnoreMouseEvents(false);
@ -42,32 +42,32 @@ export function closeAgentPetIfOpen(petId: string): void {
}
export function dismissAgentPetForActiveLease(petId: string): void {
info("familiar.agent", "dismiss requested", { petId });
info("pet.agent", "dismiss requested", { petId });
dismissedAgentPets.add(petId);
closeAgentPetIfOpen(petId);
}
export function clearAgentPetDismissal(petId: string): void {
debug("familiar.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
debug("pet.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
dismissedAgentPets.delete(petId);
}
export function clearAgentPetLeaseState(petId: string): void {
info("familiar.agent", "lease state cleared", { petId, hadWindow: agentPetWindows.has(petId), wasDismissed: dismissedAgentPets.has(petId) });
info("pet.agent", "lease state cleared", { petId, hadWindow: agentPetWindows.has(petId), wasDismissed: dismissedAgentPets.has(petId) });
dismissedAgentPets.delete(petId);
closeAgentPetIfOpen(petId);
clearAgentDisplay(petId);
}
export function applyAgentPetReaction(petId: string, reaction: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
debug("familiar.agent", "reaction apply", { petId, reaction });
export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "reaction apply", { petId, reaction });
setAgentDisplay(petId, { reaction });
const shown = showAgentPet(petId);
return shown ? { shown } : { shown, reason: "dismissed" };
}
export function applyAgentPetSay(petId: string, message: string, reaction?: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
debug("familiar.agent", "say apply", { petId, reaction, messageLength: message.length });
export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "say apply", { petId, reaction, messageLength: message.length });
if (!reaction) clearStatusBadge(petId);
setAgentDisplay(petId, { message, reaction });
const shown = showAgentPet(petId);
@ -75,7 +75,7 @@ export function applyAgentPetSay(petId: string, message: string, reaction?: Fami
}
export function closeAllAgentPets(): void {
info("familiar.agent", "close all requested", { activeWindows: agentPetWindows.size });
info("pet.agent", "close all requested", { activeWindows: agentPetWindows.size });
for (const petId of [...agentPetWindows.keys()]) {
closeAgentPetIfOpen(petId);
}
@ -83,7 +83,7 @@ export function closeAllAgentPets(): void {
}
export function refreshAgentPetContent(): void {
debug("familiar.agent", "refresh all content", { activeWindows: agentPetWindows.size, petIds: [...agentPetWindows.keys()] });
debug("pet.agent", "refresh all content", { activeWindows: agentPetWindows.size, petIds: [...agentPetWindows.keys()] });
const scale = getPreferredPetScale();
for (const [petId, window] of agentPetWindows.entries()) {
if (!window.isDestroyed()) {
@ -96,9 +96,9 @@ export function refreshAgentPetContent(): void {
function handleBubbleDismissed(petId: string, dismissToken: string): void {
const currentGeneration = displayGenerations.get(petId) ?? 0;
debug("familiar.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
debug("pet.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
if (dismissToken !== String(currentGeneration)) {
debug("familiar.agent", "bubble dismissed stale token", { petId, dismissToken, currentGeneration });
debug("pet.agent", "bubble dismissed stale token", { petId, dismissToken, currentGeneration });
return;
}
clearAgentDisplay(petId);
@ -111,21 +111,21 @@ function handleBubbleDismissed(petId: string, dismissToken: string): void {
function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
const existing = agentPetWindows.get(petId);
if (existing && !existing.isDestroyed()) {
debug("familiar.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
debug("pet.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
return existing;
}
const state = getAppStateSnapshot();
const scale = state.preferences.petScale as PetScaleValue;
const familiar = state.familiars.installed.find((candidate) => candidate.id === petId);
if (!familiar) throw new Error(`Installed familiar is unavailable: ${petId}`);
const pet = state.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 display = transientDisplays.get(petId) ?? null;
const badge = statusBadges.get(petId) ?? null;
const window = createAgentPetWindow({
petId,
displayName: familiar.displayName,
displayName: pet.displayName,
scale,
position: { x: initial.x - offset * 36, y: initial.y - offset * 24 },
display,
@ -137,17 +137,17 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
const windowId = window.id;
window.on("closed", () => {
info("familiar.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
info("pet.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
});
agentPetWindows.set(petId, window);
info("familiar.agent", "created", { petId, windowId: window.id, offset, activeWindows: agentPetWindows.size, position: { x: initial.x - offset * 36, y: initial.y - offset * 24 } });
info("pet.agent", "created", { petId, windowId: window.id, offset, activeWindows: agentPetWindows.size, position: { x: initial.x - offset * 36, y: initial.y - offset * 24 } });
return window;
}
function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
debug("familiar.agent", "display set", { petId, reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
debug("pet.agent", "display set", { petId, reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
const nextGeneration = (displayGenerations.get(petId) ?? 0) + 1;
displayGenerations.set(petId, nextGeneration);
const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, { ...display, dismissToken: String(nextGeneration) });
@ -191,7 +191,7 @@ function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
}
function clearAgentDisplay(petId: string): void {
debug("familiar.agent", "display cleared", { petId, hadDisplay: transientDisplays.has(petId), hadBadge: statusBadges.has(petId) });
debug("pet.agent", "display cleared", { petId, hadDisplay: transientDisplays.has(petId), hadBadge: statusBadges.has(petId) });
const timer = transientTimers.get(petId);
if (timer) clearTimeout(timer);
const animationTimer = transientAnimationTimers.get(petId);
@ -216,14 +216,14 @@ function clearAllAgentDisplayTimers(): void {
statusBadges.clear();
}
function setStatusBadge(petId: string, reaction: FamiliarOSReaction): void {
function setStatusBadge(petId: string, reaction: OpenPetsReaction): void {
if (reaction === "idle") {
clearStatusBadge(petId);
return;
}
statusBadges.set(petId, reaction);
debug("familiar.agent", "status badge set", { petId, reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
debug("pet.agent", "status badge set", { petId, reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
const existingTimer = statusBadgeTimers.get(petId);
if (existingTimer) clearTimeout(existingTimer);
const timer = setTimeout(() => {
@ -238,14 +238,14 @@ function setStatusBadge(petId: string, reaction: FamiliarOSReaction): void {
}
function clearStatusBadge(petId: string): void {
if (statusBadges.has(petId)) debug("familiar.agent", "status badge cleared", { petId, reaction: statusBadges.get(petId) });
if (statusBadges.has(petId)) debug("pet.agent", "status badge cleared", { petId, reaction: statusBadges.get(petId) });
statusBadges.delete(petId);
const timer = statusBadgeTimers.get(petId);
if (timer) clearTimeout(timer);
statusBadgeTimers.delete(petId);
}
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): boolean {
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}
@ -254,11 +254,11 @@ function getCurrentDismissToken(petId: string, display: PetTransientDisplay | nu
}
function handleAgentScaleChanged(scale: number): void {
debug("familiar.agent", "scale changed", { scale });
debug("pet.agent", "scale changed", { scale });
import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}).catch((error) => {
console.error("Failed to update familiar scale preference.", error);
console.error("Failed to update pet scale preference.", error);
});
}

View file

@ -1,181 +0,0 @@
import { app } from "electron";
import type { ClaudeCommandSpec, ClaudeMcpPreview, FamiliarOSCommandMode } from "@familiaros/claude";
import {
safeInstallClaudeMemory,
safeUninstallClaudeMemory,
summarizeMemoryMessages,
writeActionJournal,
type AgentSetupJournalEntry,
type JournalAction,
} from "./agent-setup-support.js";
import { getPreferredClaudeCommand } from "./agent-setup-command-context.js";
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
import type {
AgentSetupAction,
AgentSetupActionResult,
ClaudeCodeStatus,
} from "./agent-setup.js";
type CommandResult = AgentSetupCommandResult;
type AgentSetupClaudeMcpActionHelpers = {
readonly selectedPetId: string | undefined;
readonly commandMode: FamiliarOSCommandMode;
readonly detectClaudeCodeStatus: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => Promise<ClaudeCodeStatus>;
readonly formatUserPath: (path: string | undefined) => string | undefined;
readonly sanitizeOutput: (value: string) => string;
readonly summarizeCommandResult: (result: CommandResult) => string;
readonly runClaudeCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
readonly appendAgentSetupJournal: (entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }) => void;
readonly journalActionFor: (action: AgentSetupAction) => JournalAction;
};
export async function runRemoveOnlyClaudeMcpAction(
action: AgentSetupAction,
commandMode: FamiliarOSCommandMode,
helpers: AgentSetupClaudeMcpActionHelpers,
): Promise<AgentSetupActionResult> {
return runRemove(createRemoveOnlyPreview(commandMode), "Unknown", action, helpers);
}
export async function runClaudeMcpAction(
action: AgentSetupAction,
preview: ClaudeMcpPreview,
helpers: AgentSetupClaudeMcpActionHelpers,
): Promise<AgentSetupActionResult> {
const detection = await helpers.detectClaudeCodeStatus(helpers.selectedPetId, helpers.commandMode);
const previousStatus = detection.label;
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,
} satisfies AgentSetupActionResult;
helpers.appendAgentSetupJournal({
action: helpers.journalActionFor(action),
selectedPetId: helpers.selectedPetId,
command: [preview.add.command, ...preview.add.args],
previousStatus,
success: false,
message: result.message,
});
return result;
}
if (action === "configure") {
return runConfigureClaudeMcpAction(detection, preview, previousStatus, helpers);
}
if (!detection.openPetsEntry.present) {
return runAdd(preview, previousStatus, action, helpers);
}
const removed = await runRemove(preview, previousStatus, action, helpers);
if (!removed.ok) return removed;
const added = await runAdd(preview, previousStatus, action, helpers);
if (!added.ok) {
return {
ok: false,
action,
message: `${added.message} The previous familiaros entry was removed; use this command to restore the intended entry: ${preview.displayCommand}`,
changed: true,
};
}
return {
ok: true,
action,
message: `Replaced Claude Code FamiliarOS MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`,
changed: true,
};
}
function runConfigureClaudeMcpAction(
detection: ClaudeCodeStatus,
preview: ClaudeMcpPreview,
previousStatus: string,
helpers: AgentSetupClaudeMcpActionHelpers,
): Promise<AgentSetupActionResult> | AgentSetupActionResult {
if (detection.openPetsEntry.present && detection.openPetsEntry.verified && detection.openPetsEntry.matchesExpected) {
const memoryResult = safeInstallClaudeMemory(app.getPath("home"));
const message = `FamiliarOS MCP is already configured for Claude Code.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`;
return {
ok: true,
action: "configure",
message,
changed: memoryResult.ok && memoryResult.message.startsWith("Added"),
};
}
if (detection.openPetsEntry.present) {
return {
ok: false,
action: "configure",
message: "Claude already has an familiaros MCP entry. FamiliarOS will keep it as installed; use Replace only if you want to recreate it with the recommended command.",
changed: false,
};
}
return runAdd(preview, previousStatus, "configure", helpers);
}
async function runAdd(
preview: ClaudeMcpPreview,
previousStatus: string,
action: AgentSetupAction,
helpers: AgentSetupClaudeMcpActionHelpers,
): Promise<AgentSetupActionResult> {
const result = await helpers.runClaudeCommand(preview.add);
const memoryResult = result.ok ? safeInstallClaudeMemory(app.getPath("home")) : { ok: false as const, message: "" };
const message = result.ok
? `Configured Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP add failed: ${helpers.summarizeCommandResult(result)}`;
writeActionJournal({
entry: {
action: helpers.journalActionFor(action),
selectedPetId: helpers.selectedPetId,
command: [preview.add.command, ...preview.add.args],
previousStatus,
success: result.ok,
message,
},
userDataPath: app.getPath("userData"),
formatUserPath: helpers.formatUserPath,
sanitizeOutput: helpers.sanitizeOutput,
});
return { ok: result.ok, action, message, changed: result.ok };
}
async function runRemove(
preview: ClaudeMcpPreview,
previousStatus: string,
action: AgentSetupAction,
helpers: AgentSetupClaudeMcpActionHelpers,
): Promise<AgentSetupActionResult> {
const result = await helpers.runClaudeCommand(preview.remove);
const memoryResult = result.ok ? safeUninstallClaudeMemory(app.getPath("home")) : { ok: false as const, message: "" };
const message = result.ok
? `Removed Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP remove failed: ${helpers.summarizeCommandResult(result)}`;
writeActionJournal({
entry: {
action: helpers.journalActionFor(action),
selectedPetId: helpers.selectedPetId,
command: [preview.remove.command, ...preview.remove.args],
previousStatus,
success: result.ok,
message,
},
userDataPath: app.getPath("userData"),
formatUserPath: helpers.formatUserPath,
sanitizeOutput: helpers.sanitizeOutput,
});
return { ok: result.ok, action, message, changed: result.ok };
}
function createRemoveOnlyPreview(commandMode: FamiliarOSCommandMode): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
return {
commandMode,
add: { command: claude, args: [] },
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "familiaros"] },
mcpJson: { mcpServers: { familiaros: { type: "stdio", command: "node", args: [] } } },
displayCommand: "",
};
}

View file

@ -1,157 +0,0 @@
import { app } from "electron";
import type { ClaudeCommandSpec, FamiliarOSCommandMode } from "@familiaros/claude";
import {
buildCursorSetupSnapshot,
buildOpenCodeSetupSnapshot,
installCursorGlobalConfig,
installOpenCodeGlobalConfig,
removeCursorGlobalConfig,
removeOpenCodeGlobalConfig,
replaceCursorGlobalConfig,
type CursorSetupPreview,
type CursorSetupStatus,
type OpenCodeSetupPreview,
type OpenCodeSetupStatus,
} from "./agent-setup-editor-tools.js";
import { buildFamiliarOSMcpServerPreview } from "./agent-setup-support.js";
import {
getAgentSetupCliEntryPath,
getCliPackageVersion,
getMcpPackageVersion,
getOpenCodePackageVersion,
getPreferredNodeCommand,
getPreferredOpenCodeCommand,
} from "./agent-setup-command-context.js";
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
import type { AgentSetupActionResult, FamiliarOSMcpServerHealth } from "./agent-setup.js";
type CommandResult = AgentSetupCommandResult;
export async function getOpenCodeSetup(
commandMode: FamiliarOSCommandMode,
selectedPetId: string | undefined,
runCommandFn: (spec: ClaudeCommandSpec) => Promise<CommandResult>,
formatUserPath: (path: string | undefined) => string | undefined,
): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
const detected = await runCommandFn({ command: getPreferredOpenCodeCommand(), args: ["--version"] });
return buildOpenCodeSetupSnapshot({
env: process.env,
homeDir: app.getPath("home"),
processPlatform: process.platform,
selectedPetId,
commandMode,
cliVersion: getCliPackageVersion(),
pluginVersion: getOpenCodePackageVersion(),
cliEntryPath: commandMode === "published" ? undefined : getAgentSetupCliEntryPath(commandMode),
detectedOk: detected.ok,
preferredOpenCodeCommand: getPreferredOpenCodeCommand(),
formatUserPath,
});
}
export async function getCursorSetup(
commandMode: FamiliarOSCommandMode,
selectedPetId: string | undefined,
formatUserPath: (path: string | undefined) => string | undefined,
): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
void commandMode;
return buildCursorSetupSnapshot({
homeDir: app.getPath("home"),
selectedPetId,
mcpVersion: getMcpPackageVersion(),
formatUserPath,
});
}
export async function runAgentSetupMcpServerHealthCheck(
selectedPetId: string | undefined,
commandMode: FamiliarOSCommandMode,
runCommandFn: (spec: ClaudeCommandSpec) => Promise<CommandResult>,
): Promise<FamiliarOSMcpServerHealth> {
const preview = buildFamiliarOSMcpServerPreview(selectedPetId, commandMode);
const result = await runCommandFn({ command: preview.command, args: [...preview.args, "--version"] });
if (result.ok) {
return { ok: true, output: result.stdout.trim() || "MCP server responded." };
}
const errorMessage = result.error || result.stderr.trim() || `Command exited with code ${result.exitCode ?? "unknown"}.`;
return { ok: false, output: "", error: errorMessage };
}
export async function installOpenCodeGlobal(
selectedPetId: string | undefined,
commandMode: FamiliarOSCommandMode,
helpers: {
readonly runCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
readonly summarizeCommandResult: (result: CommandResult) => string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
},
): Promise<AgentSetupActionResult> {
if (commandMode === "bundled") {
const node = await helpers.runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) {
return {
ok: false,
action: "opencode-install",
message: `Node.js is required for packaged FamiliarOS commands. Open OpenCode configuration, set the Node.js command path, then try again. ${helpers.summarizeCommandResult(node)}`,
changed: false,
};
}
}
return installOpenCodeGlobalConfig({
env: process.env,
homeDir: app.getPath("home"),
processPlatform: process.platform,
selectedPetId,
commandMode,
cliVersion: getCliPackageVersion(),
pluginVersion: getOpenCodePackageVersion(),
cliEntryPath: commandMode === "published" ? undefined : getAgentSetupCliEntryPath(commandMode),
formatUserPath: helpers.formatUserPath,
});
}
export async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
return removeOpenCodeGlobalConfig({
env: process.env,
homeDir: app.getPath("home"),
processPlatform: process.platform,
});
}
export async function installCursorGlobal(
selectedPetId: string | undefined,
commandMode: FamiliarOSCommandMode,
formatUserPath: (path: string | undefined) => string | undefined,
): Promise<AgentSetupActionResult> {
void commandMode;
return installCursorGlobalConfig({
homeDir: app.getPath("home"),
selectedPetId,
mcpVersion: getMcpPackageVersion(),
formatUserPath,
});
}
export async function replaceCursorGlobal(
selectedPetId: string | undefined,
commandMode: FamiliarOSCommandMode,
formatUserPath: (path: string | undefined) => string | undefined,
): Promise<AgentSetupActionResult> {
void commandMode;
return replaceCursorGlobalConfig({
homeDir: app.getPath("home"),
selectedPetId,
mcpVersion: getMcpPackageVersion(),
formatUserPath,
});
}
export async function removeCursorGlobal(
formatUserPath: (path: string | undefined) => string | undefined,
): Promise<AgentSetupActionResult> {
return removeCursorGlobalConfig({
homeDir: app.getPath("home"),
formatUserPath,
});
}

View file

@ -1,180 +0,0 @@
import { app } from "electron";
import {
installClaudeHooks,
uninstallClaudeHooks,
type ClaudeCommandSpec,
type ClaudeHookDoctorResult,
type ClaudeMcpPreview,
type FamiliarOSCommandMode,
} from "@familiaros/claude";
import {
installCursorGlobal,
installOpenCodeGlobal,
removeCursorGlobal,
removeOpenCodeGlobal,
replaceCursorGlobal,
} from "./agent-setup-actions-tooling.js";
export {
getCursorSetup,
getOpenCodeSetup,
runAgentSetupMcpServerHealthCheck,
} from "./agent-setup-actions-tooling.js";
import {
runClaudeMcpAction,
runRemoveOnlyClaudeMcpAction,
} from "./agent-setup-actions-claude-mcp.js";
import {
createHookJournalCommand,
safeInstallClaudeMemory,
type AgentSetupJournalEntry,
type JournalAction,
} from "./agent-setup-support.js";
import {
getPreferredNodeCommand,
} from "./agent-setup-command-context.js";
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
import type {
AgentSetupAction,
AgentSetupActionResult,
ClaudeCodeStatus,
} from "./agent-setup.js";
type CommandResult = AgentSetupCommandResult;
type AgentSetupActionHelpers = {
readonly selectedPetId: string | undefined;
readonly commandMode: FamiliarOSCommandMode;
readonly safeDoctorClaudeHooks: (commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined) => ClaudeHookDoctorResult;
readonly safeBuildClaudeMcpPreview: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => { readonly preview: ClaudeMcpPreview; readonly error?: string };
readonly detectClaudeCodeStatus: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => Promise<ClaudeCodeStatus>;
readonly formatUserPath: (path: string | undefined) => string | undefined;
readonly sanitizeOutput: (value: string) => string;
readonly summarizeCommandResult: (result: CommandResult) => string;
readonly runCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
readonly runClaudeCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
readonly appendAgentSetupJournal: (entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }) => void;
readonly journalActionFor: (action: AgentSetupAction) => JournalAction;
};
export async function executeAgentSetupResolvedAction(
action: AgentSetupAction,
helpers: AgentSetupActionHelpers,
): Promise<AgentSetupActionResult> {
const immediateAction = await runImmediateAction(action, helpers);
if (immediateAction) return immediateAction;
if (helpers.commandMode === "bundled") {
const node = await helpers.runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) {
return {
ok: false,
action,
message: `Node.js is required for packaged FamiliarOS commands. Open Claude configuration, set the Node.js command path, then try again. ${helpers.summarizeCommandResult(node)}`,
changed: false,
};
}
}
const previewResult = helpers.safeBuildClaudeMcpPreview(helpers.selectedPetId, helpers.commandMode);
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
const hookInstallAction = runInstallHooksAction(action, helpers);
if (hookInstallAction) return hookInstallAction;
return runClaudeMcpAction(action, previewResult.preview, helpers);
}
async function runImmediateAction(
action: AgentSetupAction,
helpers: AgentSetupActionHelpers,
): Promise<AgentSetupActionResult | undefined> {
if (action === "opencode-install") return installOpenCodeGlobal(helpers.selectedPetId, helpers.commandMode, helpers);
if (action === "opencode-remove") return removeOpenCodeGlobal();
if (action === "cursor-install") return installCursorGlobal(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath);
if (action === "cursor-replace") return replaceCursorGlobal(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath);
if (action === "cursor-remove") return removeCursorGlobal(helpers.formatUserPath);
if (action === "doctor-hooks") {
const doctor = helpers.safeDoctorClaudeHooks(helpers.commandMode, helpers.selectedPetId);
helpers.appendAgentSetupJournal({
action: "update",
selectedPetId: helpers.selectedPetId,
command: createHookJournalCommand("doctor-hooks", helpers.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") {
return runUninstallHooksAction(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath, helpers.appendAgentSetupJournal);
}
if (action === "install-memory") {
const result = safeInstallClaudeMemory(app.getPath("home"));
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 runRemoveOnlyClaudeMcpAction(action, helpers.commandMode, helpers);
return undefined;
}
function runUninstallHooksAction(
selectedPetId: string | undefined,
commandMode: FamiliarOSCommandMode,
formatUserPath: (path: string | undefined) => string | undefined,
appendAgentSetupJournal: AgentSetupActionHelpers["appendAgentSetupJournal"],
): AgentSetupActionResult {
let result;
try {
result = uninstallClaudeHooks(undefined, commandMode);
} catch (error) {
return {
ok: false,
action: "uninstall-hooks",
message: error instanceof Error ? error.message : "FamiliarOS hook uninstall failed.",
changed: false,
};
}
const message = result.changed
? `Uninstalled FamiliarOS Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}`
: result.message;
appendAgentSetupJournal({
action: "remove",
selectedPetId,
command: ["familiaros-claude", "uninstall-hooks"],
previousStatus: result.status,
success: result.status !== "error",
message,
});
return { ok: result.status !== "error", action: "uninstall-hooks", message, changed: result.changed };
}
function runInstallHooksAction(
action: AgentSetupAction,
helpers: AgentSetupActionHelpers,
): AgentSetupActionResult | undefined {
if (action !== "install-hooks") return undefined;
let result;
try {
result = installClaudeHooks(undefined, helpers.commandMode, helpers.selectedPetId, getPreferredNodeCommand());
} catch (error) {
return {
ok: false,
action,
message: error instanceof Error ? error.message : "FamiliarOS hook install failed.",
changed: false,
};
}
const message = result.changed
? `Installed FamiliarOS Claude hooks. Backup: ${helpers.formatUserPath(result.backupPath) ?? "not needed"}`
: result.message;
helpers.appendAgentSetupJournal({
action: "update",
selectedPetId: helpers.selectedPetId,
command: createHookJournalCommand("install-hooks", helpers.selectedPetId),
previousStatus: result.status,
success: result.status !== "error",
message,
});
return { ok: result.status !== "error", action, message, changed: result.changed };
}

View file

@ -1,149 +0,0 @@
import { app } from "electron";
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, doctorClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
import { getPreferredClaudeCommand, getPreferredNodeCommand, quoteCommandForDisplay } from "./agent-setup-command-context.js";
import { delay, getAgentSetupClaudeCommandCandidates, isCommandNotFound, runAgentSetupCommand, summarizeAgentSetupCommandResult, type AgentSetupCommandResult } from "./agent-setup-command-runner.js";
import type { ClaudeCodeStatus } from "./agent-setup.js";
type CommandResult = AgentSetupCommandResult;
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);
}
export function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
try {
return { preview: withPreferredClaudeCommand(buildClaudeMcpPreview(selectedPetId, commandMode, getPreferredNodeCommand())) };
} catch (error) {
const message = error instanceof Error ? error.message : "Packaged FamiliarOS command resources are unavailable.";
return { preview: createErrorPreview(commandMode, message), error: message };
}
}
export function safeDoctorClaudeHooks(commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
try {
return doctorClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
} catch (error) {
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged FamiliarOS hook resources are unavailable.");
}
}
export async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<ClaudeCodeStatus> {
if (commandMode === "bundled") {
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return createStatus("error", "Node required", `Node.js is required for packaged FamiliarOS commands. Open Claude configuration, expand Advanced detection, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
}
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
if (!version.ok) {
const hasOverride = getPreferredClaudeCommand() !== "claude";
return createStatus("not_detected", "Not detected", `${hasOverride ? "Claude Code did not run from the saved command path" : "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, getPreferredNodeCommand());
let entry = listed;
if (listed.present) {
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode, getPreferredNodeCommand());
}
if (!entry.present) return createStatus("needs_setup", "Needs setup", "Claude Code is detected, but FamiliarOS 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 FamiliarOS MCP entry.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified) return createStatus("configured", "Installed — custom", "Claude Code has an familiaros MCP entry with a custom command. FamiliarOS 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 familiaros MCP entry, but command details were not available. FamiliarOS will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
}
export function formatUserPath(path: string | undefined): string | undefined {
if (!path) return undefined;
return path.replace(app.getPath("home"), "~");
}
export 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." };
}
export function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
return runAgentSetupCommand(spec, {
homeDir: app.getPath("home"),
platform: process.platform,
env: process.env,
sanitizeOutput: sanitizeAgentSetupOutput,
});
}
export function summarizeCommandResult(result: CommandResult): string {
return summarizeAgentSetupCommandResult(result, sanitizeAgentSetupOutput);
}
function createErrorPreview(commandMode: FamiliarOSCommandMode, message: string): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
return {
commandMode,
add: { command: claude, args: [] },
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "familiaros"] },
mcpJson: { mcpServers: { familiaros: { type: "stdio", command: "node", args: [] } } },
displayCommand: message,
};
}
function withPreferredClaudeCommand(preview: ClaudeMcpPreview): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
if (claude === preview.add.command && claude === preview.remove.command) return preview;
return {
...preview,
add: { ...preview.add, command: claude },
remove: { ...preview.remove, command: claude },
displayCommand: preview.displayCommand.replace(/^claude(?=\s|$)/, quoteCommandForDisplay(claude)),
};
}
export 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 });
}
export function createHookErrorStatus(message: string): ClaudeHookDoctorResult {
return { status: "error", settingsPath: "~/.claude/settings.json", exists: false, valid: false, message, preview: {}, asyncSupported: false };
}
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 getClaudeCommandCandidates(command: string): readonly string[] {
return getAgentSetupClaudeCommandCandidates(command, getPreferredClaudeCommand(), process.platform);
}

View file

@ -1,111 +0,0 @@
import { createRequire } from "node:module";
import { accessSync, constants, readFileSync, statSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { mapAsarPathToUnpacked, type FamiliarOSCommandMode } from "@familiaros/claude";
import { getAppStateSnapshot, updatePreferences, type FamiliarOSStateV1 } from "./app-state.js";
type Writable<T> = { -readonly [K in keyof T]: T[K] };
const require = createRequire(import.meta.url);
export function getStoredAgentSetupCommandPaths(): {
readonly claude: string;
readonly node: string;
readonly opencode: string;
} {
const preferences = getAppStateSnapshot().preferences;
return {
claude: preferences.claudeCommandPath ?? "",
node: preferences.nodeCommandPath ?? "",
opencode: preferences.opencodeCommandPath ?? "",
};
}
export function updateStoredAgentSetupCommandPaths(patch: unknown): {
readonly claude: string;
readonly node: string;
readonly opencode: string;
} {
if (!isRecord(patch)) throw new Error("Invalid command path settings.");
for (const key of Object.keys(patch)) {
if (key !== "claude" && key !== "node" && key !== "opencode") {
throw new Error("Invalid command path setting.");
}
}
const updates: Writable<Partial<FamiliarOSStateV1["preferences"]>> = {};
if ("claude" in patch) updates.claudeCommandPath = normalizeOptionalCommandPath(patch.claude, "Claude");
if ("node" in patch) updates.nodeCommandPath = normalizeOptionalCommandPath(patch.node, "Node.js");
if ("opencode" in patch) updates.opencodeCommandPath = normalizeOptionalCommandPath(patch.opencode, "OpenCode");
updatePreferences(updates);
return getStoredAgentSetupCommandPaths();
}
export function getPreferredClaudeCommand(): string {
return getAppStateSnapshot().preferences.claudeCommandPath || "claude";
}
export function getPreferredNodeCommand(): string {
return getAppStateSnapshot().preferences.nodeCommandPath || "node";
}
export function getPreferredOpenCodeCommand(): string {
return getAppStateSnapshot().preferences.opencodeCommandPath || (process.platform === "win32" ? "opencode.cmd" : "opencode");
}
export function quoteCommandForDisplay(command: string): string {
return /\s/.test(command) ? JSON.stringify(command) : command;
}
export function getAgentSetupCliEntryPath(commandMode: FamiliarOSCommandMode): string {
const path = require.resolve("@familiaros/cli");
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
}
export function getCliPackageVersion(): string {
return getWorkspacePackageVersion("@familiaros/cli");
}
export function getOpenCodePackageVersion(): string {
return getWorkspacePackageVersion("@familiaros/opencode");
}
export function getMcpPackageVersion(): string {
return getWorkspacePackageVersion("@familiaros/mcp");
}
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 normalizeOptionalCommandPath(value: unknown, label: string): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") throw new Error(`${label} command path must be text.`);
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > 4096 || /[\r\n\0]/.test(trimmed)) throw new Error(`${label} command path is invalid.`);
if (!isAbsolute(trimmed)) throw new Error(`${label} command path must be a full absolute path.`);
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) {
throw new Error(`${label} command path contains unsupported shell characters.`);
}
try {
const stat = statSync(trimmed);
if (!stat.isFile()) throw new Error();
if (process.platform !== "win32") accessSync(trimmed, constants.X_OK);
} catch {
throw new Error(`${label} command path must point to an existing executable file.`);
}
return trimmed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -1,198 +0,0 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
const defaultCommandTimeoutMs = 6_000;
const defaultMaxOutputBytes = 16_384;
export interface AgentSetupCommandSpec {
readonly command: string;
readonly args: readonly string[];
}
export interface AgentSetupCommandResult {
readonly ok: boolean;
readonly timedOut: boolean;
readonly exitCode: number | null;
readonly stdout: string;
readonly stderr: string;
readonly error?: string;
}
export interface AgentSetupCommandRuntimeOptions {
readonly homeDir: string;
readonly platform: NodeJS.Platform;
readonly env: NodeJS.ProcessEnv;
readonly sanitizeOutput: (value: string) => string;
readonly timeoutMs?: number;
readonly maxOutputBytes?: number;
}
export async function runAgentSetupCommand(
spec: AgentSetupCommandSpec,
options: AgentSetupCommandRuntimeOptions,
): Promise<AgentSetupCommandResult> {
return new Promise((resolve) => {
const command = options.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
const args = options.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
let child;
try {
child = spawn(command, args, {
cwd: options.homeDir,
env: createAgentSetupCommandEnv(options),
windowsHide: true,
shell: false,
});
} catch (error) {
resolve({
ok: false,
timedOut: false,
exitCode: null,
stdout: "",
stderr: "",
error: error instanceof Error ? error.message : "Command failed to start.",
});
return;
}
const timeoutMs = options.timeoutMs ?? defaultCommandTimeoutMs;
const maxOutputBytes = options.maxOutputBytes ?? defaultMaxOutputBytes;
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: options.sanitizeOutput(stdout),
stderr: options.sanitizeOutput(stderr),
error: "Command timed out.",
});
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout = appendBounded(stdout, chunk.toString("utf8"), maxOutputBytes);
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr = appendBounded(stderr, chunk.toString("utf8"), maxOutputBytes);
});
child.on("error", (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
ok: false,
timedOut: false,
exitCode: null,
stdout: options.sanitizeOutput(stdout),
stderr: options.sanitizeOutput(stderr),
error: error.message,
});
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
ok: code === 0,
timedOut: false,
exitCode: code,
stdout: options.sanitizeOutput(stdout),
stderr: options.sanitizeOutput(stderr),
error: undefined,
});
});
});
}
export function getAgentSetupClaudeCommandCandidates(
command: string,
preferredClaudeCommand: string,
platform: NodeJS.Platform,
): readonly string[] {
if (command !== "claude") return [command];
if (preferredClaudeCommand !== "claude") return [preferredClaudeCommand];
if (platform === "win32") return ["claude", "claude.cmd"];
return ["claude"];
}
export function createAgentSetupCommandEnv(
options: Pick<AgentSetupCommandRuntimeOptions, "platform" | "env" | "homeDir">,
): NodeJS.ProcessEnv {
const separator = options.platform === "win32" ? ";" : ":";
const existingPath = options.env.PATH ?? "";
return {
...options.env,
PATH: dedupePathEntries([existingPath, ...getAgentSetupExtraCommandPaths(options)], separator).join(separator),
};
}
export function getAgentSetupExtraCommandPaths(
options: Pick<AgentSetupCommandRuntimeOptions, "platform" | "env" | "homeDir">,
): readonly string[] {
if (options.platform === "win32") return [];
const home = options.homeDir;
const env = options.env;
return filterExistingPaths([
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
join(home, "bin"),
join(home, ".local", "bin"),
join(home, ".opencode", "bin"),
join(env.VOLTA_HOME || join(home, ".volta"), "bin"),
join(env.BUN_INSTALL || join(home, ".bun"), "bin"),
join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"),
join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"),
env.PNPM_HOME,
join(home, ".local", "share", "pnpm"),
join(home, "Library", "pnpm"),
join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"),
]);
}
export function summarizeAgentSetupCommandResult(
result: AgentSetupCommandResult,
sanitizeOutput: (value: string) => string,
): string {
if (result.timedOut) return "command timed out.";
const output = sanitizeOutput(result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`);
return output || "command failed.";
}
export function isCommandNotFound(result: AgentSetupCommandResult): boolean {
return Boolean(result.error && /ENOENT|not found/i.test(result.error));
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] {
return paths.filter((path): path is string => Boolean(path && existsSync(path)));
}
function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] {
const seen = new Set<string>();
const entries: string[] = [];
for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) {
if (seen.has(path)) continue;
seen.add(path);
entries.push(path);
}
return entries;
}
function appendBounded(existing: string, next: string, maxOutputBytes: number): string {
const combined = existing + next;
return combined.length > maxOutputBytes ? combined.slice(combined.length - maxOutputBytes) : combined;
}

View file

@ -1,180 +0,0 @@
import { buildCursorRulesPreview, buildFamiliarOSOnlyPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult, type RedactedPreview } from "@familiaros/cursor";
export interface CursorSetupStatus {
readonly state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update";
readonly label: string;
readonly details: string;
readonly configPath: string;
readonly canInstall: boolean;
readonly canReplace: boolean;
readonly canRemove: boolean;
}
export interface CursorSetupPreview {
readonly global: true;
readonly configPath: string;
readonly mcpEntry: RedactedPreview;
readonly rulesPath: string;
readonly rulesContent: string;
readonly commandMode: "published" | "local" | "bundled";
}
type CursorActionResult = {
readonly ok: boolean;
readonly action: "cursor-install" | "cursor-replace" | "cursor-remove";
readonly message: string;
readonly changed: boolean;
};
export function buildCursorSetupSnapshot({
homeDir,
selectedPetId,
mcpVersion,
formatUserPath,
}: {
readonly homeDir: string;
readonly selectedPetId: string | undefined;
readonly mcpVersion: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): { readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview } {
const configPath = getCursorGlobalMcpPath(homeDir);
const petId = selectedPetId || undefined;
const configResult = readCursorMcpConfig(configPath);
const statusResult = classifyCursorMcpStatus(configResult, configPath, { mcpVersion, petId, commandMode: "published" });
return {
status: {
state: mapCursorStatusToState(statusResult.status),
label: mapCursorStatusToLabel(statusResult.status),
details: statusResult.message,
configPath: formatUserPath(configPath) ?? configPath,
canInstall: statusResult.canInstall,
canReplace: statusResult.canReplace,
canRemove: statusResult.canRemove,
},
preview: {
global: true,
configPath: formatUserPath(configPath) ?? configPath,
mcpEntry: buildFamiliarOSOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
rulesPath: ".cursor/rules/familiaros.mdc",
rulesContent: buildCursorRulesPreview(),
commandMode: "published",
},
};
}
export function installCursorGlobalConfig({
homeDir,
selectedPetId,
mcpVersion,
formatUserPath,
}: {
readonly homeDir: string;
readonly selectedPetId: string | undefined;
readonly mcpVersion: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): CursorActionResult {
try {
const configPath = getCursorGlobalMcpPath(homeDir);
const plan = planCursorMcpInstall(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-install", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-install", message: `Installed Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-install", message: "Failed to plan Cursor MCP install.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-install", message: error instanceof Error ? error.message : "Cursor MCP install failed.", changed: false };
}
}
export function replaceCursorGlobalConfig({
homeDir,
selectedPetId,
mcpVersion,
formatUserPath,
}: {
readonly homeDir: string;
readonly selectedPetId: string | undefined;
readonly mcpVersion: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): CursorActionResult {
try {
const configPath = getCursorGlobalMcpPath(homeDir);
const plan = planCursorMcpReplace(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-replace", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-replace", message: `Replaced Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-replace", message: "Failed to plan Cursor MCP replace.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-replace", message: error instanceof Error ? error.message : "Cursor MCP replace failed.", changed: false };
}
}
export function removeCursorGlobalConfig({
homeDir,
formatUserPath,
}: {
readonly homeDir: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): CursorActionResult {
try {
const configPath = getCursorGlobalMcpPath(homeDir);
const plan = planCursorMcpRemove(configPath);
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-remove", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
return { ok: true, action: "cursor-remove", message: `Removed Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}. Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-remove", message: "Failed to plan Cursor MCP remove.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-remove", message: error instanceof Error ? error.message : "Cursor MCP remove failed.", changed: false };
}
}
function mapCursorStatusToState(status: CursorMcpStatusResult["status"]): CursorSetupStatus["state"] {
switch (status) {
case "installed":
return "configured";
case "missing":
return "needs_setup";
case "needs-update":
return "needs_update";
case "conflict":
return "conflict";
case "invalid":
case "error":
return "error";
default:
return "error";
}
}
function mapCursorStatusToLabel(status: CursorMcpStatusResult["status"]): string {
switch (status) {
case "installed":
return "Configured";
case "missing":
return "Not configured";
case "needs-update":
return "Needs update";
case "conflict":
return "Conflict";
case "invalid":
case "error":
return "Config error";
default:
return "Checking";
}
}

View file

@ -1,152 +0,0 @@
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
import type { FamiliarOSCommandMode } from "@familiaros/claude";
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>;
}
type OpenCodeActionResult = {
readonly ok: boolean;
readonly action: "opencode-install" | "opencode-remove";
readonly message: string;
readonly changed: boolean;
};
export function buildOpenCodeSetupSnapshot({
env,
homeDir,
processPlatform,
selectedPetId,
commandMode,
cliVersion,
pluginVersion,
cliEntryPath,
detectedOk,
preferredOpenCodeCommand,
formatUserPath,
}: {
readonly env: NodeJS.ProcessEnv;
readonly homeDir: string;
readonly processPlatform: NodeJS.Platform;
readonly selectedPetId: string | undefined;
readonly commandMode: FamiliarOSCommandMode;
readonly cliVersion: string;
readonly pluginVersion: string;
readonly cliEntryPath: string | undefined;
readonly detectedOk: boolean;
readonly preferredOpenCodeCommand: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): { readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview } {
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
const petId = selectedPetId || undefined;
const prepared = safePrepareOpenCode(configDir, petId, cliVersion, pluginVersion, commandMode, cliEntryPath);
const globalState = doctorOpenCodeGlobalSetup(configDir);
const configured = globalState.status === "installed";
return {
status: {
state: globalState.status === "error" || globalState.status === "custom" || globalState.status === "conflict" ? "error" : configured ? "configured" : detectedOk ? "needs_setup" : "not_detected",
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detectedOk ? "Ready" : "Not detected",
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detectedOk ? "OpenCode was detected. Desktop setup writes global OpenCode config." : preferredOpenCodeCommand === (processPlatform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
configDir: formatUserPath(configDir) ?? configDir,
canInstall: prepared.ok && !configured,
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 ? [`@familiaros/opencode@${pluginVersion}`, { familiar: petId }] : `@familiaros/opencode@${pluginVersion}`),
instructionPath: prepared.ok ? (formatUserPath(prepared.instructionPath) ?? prepared.instructionPath) : "",
configPreview: prepared.ok ? prepared.configPreview : {},
},
};
}
export function installOpenCodeGlobalConfig({
env,
homeDir,
processPlatform,
selectedPetId,
commandMode,
cliVersion,
pluginVersion,
cliEntryPath,
formatUserPath,
}: {
readonly env: NodeJS.ProcessEnv;
readonly homeDir: string;
readonly processPlatform: NodeJS.Platform;
readonly selectedPetId: string | undefined;
readonly commandMode: FamiliarOSCommandMode;
readonly cliVersion: string;
readonly pluginVersion: string;
readonly cliEntryPath: string | undefined;
readonly formatUserPath: (path: string | undefined) => string | undefined;
}): OpenCodeActionResult {
try {
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion, pluginVersion, commandMode, cliEntryPath });
writePreparedOpenCodeGlobalSetup(prepared);
return { ok: true, action: "opencode-install", message: `Installed global OpenCode FamiliarOS 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 };
}
}
export function removeOpenCodeGlobalConfig({
env,
homeDir,
processPlatform,
}: {
readonly env: NodeJS.ProcessEnv;
readonly homeDir: string;
readonly processPlatform: NodeJS.Platform;
}): OpenCodeActionResult {
try {
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
const prepared = prepareOpenCodeGlobalRemove(configDir);
writePreparedOpenCodeGlobalRemove(prepared);
return { ok: true, action: "opencode-remove", message: prepared.configWrites.length > 0 ? "Removed global OpenCode FamiliarOS setup." : "Global OpenCode FamiliarOS 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 safePrepareOpenCode(
configDir: string,
selectedPetId: string | undefined,
cliVersion: string,
pluginVersion: string,
commandMode: FamiliarOSCommandMode,
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?: { familiaros?: { 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?.familiaros?.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." };
}
}

View file

@ -1,19 +0,0 @@
export {
buildOpenCodeSetupSnapshot,
installOpenCodeGlobalConfig,
removeOpenCodeGlobalConfig,
} from "./agent-setup-editor-tools-opencode.js";
export type {
OpenCodeSetupPreview,
OpenCodeSetupStatus,
} from "./agent-setup-editor-tools-opencode.js";
export {
buildCursorSetupSnapshot,
installCursorGlobalConfig,
replaceCursorGlobalConfig,
removeCursorGlobalConfig,
} from "./agent-setup-editor-tools-cursor.js";
export type {
CursorSetupPreview,
CursorSetupStatus,
} from "./agent-setup-editor-tools-cursor.js";

View file

@ -1,130 +0,0 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { buildFamiliarOSMcpServerCommand, formatCommandForDisplay, type FamiliarOSCommandMode } from "@familiaros/claude";
import { getPreferredNodeCommand } from "./agent-setup-command-context.js";
import { installClaudeFamiliarOSMemory, uninstallClaudeFamiliarOSMemory } from "./claude-memory.js";
export type JournalAction = "configure" | "update" | "replace" | "remove";
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;
}
export interface FamiliarOSMcpServerPreview {
readonly commandMode: FamiliarOSCommandMode;
readonly command: string;
readonly args: readonly string[];
readonly displayCommand: string;
readonly mcpJson: {
readonly mcpServers: {
readonly familiaros: {
readonly type: "stdio";
readonly command: string;
readonly args: readonly string[];
};
};
};
}
type MemoryInstallResult = { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string };
type HookJournalCommand = "doctor-hooks" | "install-hooks";
type JournalWriteEntry = Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string };
export function buildFamiliarOSMcpServerPreview(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): FamiliarOSMcpServerPreview {
const nodeCommand = getPreferredNodeCommand();
const { command, args } = buildFamiliarOSMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const displayCommand = formatCommandForDisplay({ command, args });
return {
commandMode,
command,
args,
displayCommand,
mcpJson: {
mcpServers: {
familiaros: { type: "stdio", command, args },
},
},
};
}
export function summarizeMemoryMessages(...messages: readonly string[]): string {
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:FamiliarOS )?instructions[^.]*\./g) ?? []);
return memoryMessages.length > 0 ? ` ${memoryMessages.join(" ")}` : "";
}
export function createHookJournalCommand(command: HookJournalCommand, selectedPetId: string | undefined): readonly string[] {
return selectedPetId ? ["familiaros-claude", command, "--familiar", selectedPetId] : ["familiaros-claude", command];
}
export function safeInstallClaudeMemory(homeDir: string): MemoryInstallResult {
try {
const result = installClaudeFamiliarOSMemory(homeDir);
return { ok: true, message: result.changed ? "Added Claude FamiliarOS instructions." : "Claude FamiliarOS instructions already present." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
}
export function safeUninstallClaudeMemory(homeDir: string): MemoryInstallResult {
try {
const result = uninstallClaudeFamiliarOSMemory(homeDir);
return { ok: true, message: result.changed ? "Removed Claude FamiliarOS instructions." : "Claude FamiliarOS instructions were already absent." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
}
export function writeActionJournal({
entry,
userDataPath,
formatUserPath,
sanitizeOutput,
}: {
readonly entry: JournalWriteEntry;
readonly userDataPath: string;
readonly formatUserPath: (path: string | undefined) => string | undefined;
readonly sanitizeOutput: (value: string) => string;
}): void {
try {
const path = getJournalPath(userDataPath);
const entries = readActionJournal(userDataPath).concat({
...entry,
command: entry.command.map((part) => formatUserPath(part) ?? part),
message: sanitizeOutput(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 FamiliarOS agent setup action journal.", error);
}
}
function readActionJournal(userDataPath: string): AgentSetupJournalEntry[] {
const path = getJournalPath(userDataPath);
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(userDataPath: string): string {
return join(userDataPath, "agent-setup-actions.json");
}
function isJournalEntry(value: unknown): value is AgentSetupJournalEntry {
return typeof value === "object" && value !== null && typeof (value as { timestamp?: unknown }).timestamp === "string";
}

View file

@ -1,41 +1,19 @@
import { app } from "electron";
import { createFamiliarOSHookSettingsPreview, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
import { spawn } from "node:child_process";
import { constants, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, accessSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { createRequire } from "node:module";
import type { CursorSetupPreview, CursorSetupStatus, OpenCodeSetupPreview, OpenCodeSetupStatus } from "./agent-setup-editor-tools.js";
import {
executeAgentSetupResolvedAction,
getCursorSetup,
getOpenCodeSetup,
runAgentSetupMcpServerHealthCheck,
} from "./agent-setup-actions.js";
import {
createBundledResourceErrorStatus,
createHookErrorStatus,
detectClaudeCodeStatus,
formatUserPath,
runClaudeCommand,
runCommand,
safeBuildClaudeMcpPreview,
safeDoctorClaudeHooks,
sanitizeAgentSetupOutput,
summarizeCommandResult,
} from "./agent-setup-claude-status.js";
import {
writeActionJournal,
type AgentSetupJournalEntry,
type JournalAction,
} from "./agent-setup-support.js";
import {
getStoredAgentSetupCommandPaths,
updateStoredAgentSetupCommandPaths,
} from "./agent-setup-command-context.js";
import { getAppStateSnapshot, type InstalledPetState } from "./app-state.js";
import { doctorClaudeFamiliarOSMemory, type ClaudeFamiliarOSMemoryStatus } from "./claude-memory.js";
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 { buildCursorRulesPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult } from "@open-pets/cursor";
import { buildOpenPetsOnlyPreview, type RedactedPreview } from "@open-pets/cursor";
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type OpenPetsStateV1 } 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" | "cursor-install" | "cursor-replace" | "cursor-remove";
export { buildFamiliarOSMcpServerPreview } from "./agent-setup-support.js";
export type { AgentSetupJournalEntry, FamiliarOSMcpServerPreview, JournalAction } from "./agent-setup-support.js";
export { sanitizeAgentSetupOutput } from "./agent-setup-claude-status.js";
export type JournalAction = "configure" | "update" | "replace" | "remove";
export interface AgentSetupPetOption {
readonly id: string;
@ -58,13 +36,13 @@ export interface ClaudeCodeStatus {
export interface AgentSetupSnapshot {
readonly selectedPetId?: string;
readonly commandMode: FamiliarOSCommandMode;
readonly commandMode: OpenPetsCommandMode;
readonly localDevAvailable: boolean;
readonly petOptions: readonly AgentSetupPetOption[];
readonly preview: ClaudeMcpPreview;
readonly status: ClaudeCodeStatus;
readonly hookStatus: ClaudeHookDoctorResult;
readonly memoryStatus: ClaudeFamiliarOSMemoryStatus;
readonly memoryStatus: ClaudeOpenPetsMemoryStatus;
readonly opencodeStatus: OpenCodeSetupStatus;
readonly opencodePreview: OpenCodeSetupPreview;
readonly cursorStatus: CursorSetupStatus;
@ -80,7 +58,44 @@ export interface AgentSetupCommandPaths {
readonly opencode: string;
}
export type { CursorSetupPreview, CursorSetupStatus, OpenCodeSetupPreview, OpenCodeSetupStatus } from "./agent-setup-editor-tools.js";
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 CursorSetupStatus {
readonly state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update";
readonly label: string;
readonly details: string;
readonly configPath: string;
readonly canInstall: boolean;
readonly canReplace: boolean;
readonly canRemove: boolean;
}
export interface CursorSetupPreview {
readonly global: true;
readonly configPath: string;
readonly mcpEntry: RedactedPreview;
readonly rulesPath: string;
readonly rulesContent: string;
readonly commandMode: "published" | "local" | "bundled";
}
export interface AgentSetupActionResult {
readonly ok: boolean;
@ -89,6 +104,28 @@ export interface AgentSetupActionResult {
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;
@ -99,10 +136,10 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, 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 = doctorClaudeFamiliarOSMemory(app.getPath("home"));
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, runCommand, formatUserPath);
const cursor = await getCursorSetup(commandMode, petId, formatUserPath);
const opencode = await getOpenCodeSetup(commandMode, petId);
const cursor = await getCursorSetup(commandMode, petId);
return {
selectedPetId: petId,
@ -117,16 +154,27 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandMode
opencodePreview: opencode.preview,
cursorStatus: cursor.status,
cursorPreview: cursor.preview,
commandPaths: getStoredAgentSetupCommandPaths(),
commandPaths: getAgentSetupCommandPaths(),
busy: operationRunning,
lastAction,
};
}
export function updateAgentSetupCommandPaths(patch: unknown): AgentSetupCommandPaths {
return updateStoredAgentSetupCommandPaths(patch);
if (!isRecord(patch)) throw new Error("Invalid command path settings.");
for (const key of Object.keys(patch)) {
if (key !== "claude" && key !== "node" && key !== "opencode") throw new Error("Invalid command path setting.");
}
const updates: Writable<Partial<OpenPetsStateV1["preferences"]>> = {};
if ("claude" in patch) updates.claudeCommandPath = normalizeOptionalCommandPath(patch.claude, "Claude");
if ("node" in patch) updates.nodeCommandPath = normalizeOptionalCommandPath(patch.node, "Node.js");
if ("opencode" in patch) updates.opencodeCommandPath = normalizeOptionalCommandPath(patch.opencode, "OpenCode");
updatePreferences(updates);
return getAgentSetupCommandPaths();
}
type Writable<T> = { -readonly [K in keyof T]: T[K] };
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);
@ -134,20 +182,7 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
operationRunning = true;
try {
lastAction = await executeAgentSetupResolvedAction(action, {
selectedPetId: petId,
commandMode,
safeDoctorClaudeHooks,
safeBuildClaudeMcpPreview,
detectClaudeCodeStatus,
formatUserPath,
sanitizeOutput: sanitizeAgentSetupOutput,
summarizeCommandResult,
runCommand,
runClaudeCommand,
appendAgentSetupJournal,
journalActionFor,
});
lastAction = await runAction(action, petId, commandMode);
operationRunning = false;
return getAgentSetupSnapshot(petId, commandMode);
} finally {
@ -155,45 +190,705 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
}
}
function appendAgentSetupJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
writeActionJournal({
entry,
userDataPath: app.getPath("userData"),
formatUserPath,
sanitizeOutput: sanitizeAgentSetupOutput,
});
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);
}
export interface FamiliarOSMcpServerHealth {
readonly ok: boolean;
readonly output: string;
readonly error?: string;
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
try {
return { preview: withPreferredClaudeCommand(buildClaudeMcpPreview(selectedPetId, commandMode, getPreferredNodeCommand())) };
} catch (error) {
const message = error instanceof Error ? error.message : "Packaged OpenPets command resources are unavailable.";
return { preview: createErrorPreview(commandMode, message), error: message };
}
}
export async function testFamiliarOSMcpServer(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<FamiliarOSMcpServerHealth> {
return runAgentSetupMcpServerHealthCheck(selectedPetId, commandMode, runCommand);
function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
try {
return doctorClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
} catch (error) {
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable.");
}
}
function createErrorPreview(commandMode: OpenPetsCommandMode, message: string): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
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 withPreferredClaudeCommand(preview: ClaudeMcpPreview): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
if (claude === preview.add.command && claude === preview.remove.command) return preview;
return {
...preview,
add: { ...preview.add, command: claude },
remove: { ...preview.remove, command: claude },
displayCommand: preview.displayCommand.replace(/^claude(?=\s|$)/, quoteCommandForDisplay(claude)),
};
}
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 === "cursor-install") return installCursorGlobal(selectedPetId, commandMode);
if (action === "cursor-replace") return replaceCursorGlobal(selectedPetId, commandMode);
if (action === "cursor-remove") return removeCursorGlobal();
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: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return { ok: false, action, message: `Node.js is required for packaged OpenPets commands. Open Claude configuration, set the Node.js command path, then try again. ${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, getPreferredNodeCommand());
} 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: getPreferredOpenCodeCommand(), 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." : getPreferredOpenCodeCommand() === (process.platform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
configDir: formatUserPath(configDir) ?? configDir,
canInstall: prepared.ok && !configured,
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 : {},
},
};
}
async function getCursorSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
const homeDir = app.getPath("home");
const configPath = getCursorGlobalMcpPath(homeDir);
const petId = selectedPetId || undefined;
const mcpVersion = getMcpPackageVersion();
const configResult = readCursorMcpConfig(configPath);
const statusResult = classifyCursorMcpStatus(configResult, configPath, { mcpVersion, petId, commandMode: "published" });
const state = mapCursorStatusToState(statusResult.status);
const label = mapCursorStatusToLabel(statusResult.status);
const details = statusResult.message;
return {
status: {
state,
label,
details,
configPath: formatUserPath(configPath) ?? configPath,
canInstall: statusResult.canInstall,
canReplace: statusResult.canReplace,
canRemove: statusResult.canRemove,
},
preview: {
global: true,
configPath: formatUserPath(configPath) ?? configPath,
mcpEntry: buildOpenPetsOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
rulesPath: ".cursor/rules/openpets.mdc",
rulesContent: buildCursorRulesPreview(),
commandMode: "published",
},
};
}
function mapCursorStatusToState(status: CursorMcpStatusResult["status"]): CursorSetupStatus["state"] {
switch (status) {
case "installed":
return "configured";
case "missing":
return "needs_setup";
case "needs-update":
return "needs_update";
case "conflict":
return "conflict";
case "invalid":
case "error":
return "error";
default:
return "error";
}
}
function mapCursorStatusToLabel(status: CursorMcpStatusResult["status"]): string {
switch (status) {
case "installed":
return "Configured";
case "missing":
return "Not configured";
case "needs-update":
return "Needs update";
case "conflict":
return "Conflict";
case "invalid":
case "error":
return "Config error";
default:
return "Checking";
}
}
function getAgentSetupCommandPaths(): AgentSetupCommandPaths {
const preferences = getAppStateSnapshot().preferences;
return {
claude: preferences.claudeCommandPath ?? "",
node: preferences.nodeCommandPath ?? "",
opencode: preferences.opencodeCommandPath ?? "",
};
}
function getPreferredClaudeCommand(): string {
return getAppStateSnapshot().preferences.claudeCommandPath || "claude";
}
function getPreferredNodeCommand(): string {
return getAppStateSnapshot().preferences.nodeCommandPath || "node";
}
function getPreferredOpenCodeCommand(): string {
return getAppStateSnapshot().preferences.opencodeCommandPath || (process.platform === "win32" ? "opencode.cmd" : "opencode");
}
function normalizeOptionalCommandPath(value: unknown, label: string): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") throw new Error(`${label} command path must be text.`);
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > 4096 || /[\r\n\0]/.test(trimmed)) throw new Error(`${label} command path is invalid.`);
if (!isAbsolute(trimmed)) throw new Error(`${label} command path must be a full absolute path.`);
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) throw new Error(`${label} command path contains unsupported shell characters.`);
try {
const stat = statSync(trimmed);
if (!stat.isFile()) throw new Error();
if (process.platform !== "win32") accessSync(trimmed, constants.X_OK);
} catch {
throw new Error(`${label} command path must point to an existing executable file.`);
}
return trimmed;
}
function quoteCommandForDisplay(command: string): string {
return /\s/.test(command) ? JSON.stringify(command) : command;
}
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: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return { ok: false, action: "opencode-install", message: `Node.js is required for packaged OpenPets commands. Open OpenCode configuration, set the Node.js command path, then try again. ${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 };
}
}
async function installCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
void commandMode;
try {
const homeDir = app.getPath("home");
const configPath = getCursorGlobalMcpPath(homeDir);
const mcpVersion = getMcpPackageVersion();
const plan = planCursorMcpInstall(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-install", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-install", message: `Installed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-install", message: "Failed to plan Cursor MCP install.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-install", message: error instanceof Error ? error.message : "Cursor MCP install failed.", changed: false };
}
}
async function replaceCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
void commandMode;
try {
const homeDir = app.getPath("home");
const configPath = getCursorGlobalMcpPath(homeDir);
const mcpVersion = getMcpPackageVersion();
const plan = planCursorMcpReplace(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-replace", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-replace", message: `Replaced Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-replace", message: "Failed to plan Cursor MCP replace.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-replace", message: error instanceof Error ? error.message : "Cursor MCP replace failed.", changed: false };
}
}
async function removeCursorGlobal(): Promise<AgentSetupActionResult> {
try {
const homeDir = app.getPath("home");
const configPath = getCursorGlobalMcpPath(homeDir);
const plan = planCursorMcpRemove(configPath);
if ("ok" in plan && !plan.ok) {
return { ok: false, action: "cursor-remove", message: plan.message, changed: false };
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
return { ok: true, action: "cursor-remove", message: `Removed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}. Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-remove", message: "Failed to plan Cursor MCP remove.", changed: false };
} catch (error) {
return { ok: false, action: "cursor-remove", message: error instanceof Error ? error.message : "Cursor MCP remove 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 getMcpPackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/mcp");
}
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: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return createStatus("error", "Node required", `Node.js is required for packaged OpenPets commands. Open Claude configuration, expand Advanced detection, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
}
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
if (!version.ok) {
const hasOverride = getPreferredClaudeCommand() !== "claude";
return createStatus("not_detected", "Not detected", `${hasOverride ? "Claude Code did not run from the saved command path" : "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, getPreferredNodeCommand());
let entry = listed;
if (listed.present) {
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode, getPreferredNodeCommand());
}
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 familiar id.");
const familiar = getAppStateSnapshot().familiars.installed.find((candidate) => candidate.id === value);
if (!familiar || familiar.broken) throw new Error("Selected familiar is not installed or is broken.");
return familiar.id;
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): FamiliarOSCommandMode {
function validateCommandMode(value: unknown): OpenPetsCommandMode {
if (app.isPackaged) return "bundled";
return value === "local" ? "local" : "published";
}
function getPetOptions(): readonly AgentSetupPetOption[] {
const state = getAppStateSnapshot();
return state.familiars.installed.filter(isUsablePet).map((familiar) => ({ id: familiar.id, displayName: familiar.displayName, default: familiar.id === state.preferences.defaultPetId }));
return state.pets.installed.filter(isUsablePet).map((pet) => ({ id: pet.id, displayName: pet.displayName, default: pet.id === state.preferences.defaultPetId }));
}
function isUsablePet(familiar: InstalledPetState): boolean {
return familiar.installed && !familiar.broken && !familiar.builtIn;
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;
let child;
try {
child = spawn(command, args, { cwd: app.getPath("home"), env: createCommandEnv(), windowsHide: true, shell: false });
} catch (error) {
resolve({ ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : "Command failed to start." });
return;
}
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];
const preferred = getPreferredClaudeCommand();
if (preferred !== "claude") return [preferred];
if (process.platform === "win32") return ["claude", "claude.cmd"];
return ["claude"];
}
function createCommandEnv(): NodeJS.ProcessEnv {
const separator = process.platform === "win32" ? ";" : ":";
const existingPath = process.env.PATH ?? "";
return { ...process.env, PATH: dedupePathEntries([existingPath, ...getExtraCommandPaths()], separator).join(separator) };
}
function getExtraCommandPaths(): readonly string[] {
if (process.platform === "win32") return [];
const home = app.getPath("home");
const env = process.env;
return filterExistingPaths([
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
join(home, "bin"),
join(home, ".local", "bin"),
join(home, ".opencode", "bin"),
join(env.VOLTA_HOME || join(home, ".volta"), "bin"),
join(env.BUN_INSTALL || join(home, ".bun"), "bin"),
join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"),
join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"),
env.PNPM_HOME,
join(home, ".local", "share", "pnpm"),
join(home, "Library", "pnpm"),
join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"),
]);
}
function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] {
return paths.filter((path): path is string => Boolean(path && existsSync(path)));
}
function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] {
const seen = new Set<string>();
const entries: string[] = [];
for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) {
if (seen.has(path)) continue;
seen.add(path);
entries.push(path);
}
return entries;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
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 {
@ -204,5 +899,5 @@ function journalActionFor(action: AgentSetupAction): JournalAction {
export const agentSetupInternalsForChecks = {
sanitizeAgentSetupOutput,
createFamiliarOSHookSettingsPreview,
createOpenPetsHookSettingsPreview,
};

View file

@ -1,96 +0,0 @@
import { builtInPet } from "./built-in-familiar.js";
import { allowedReactions, type FamiliarOSReaction } from "./local-ipc-protocol.js";
const installedPetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export interface FamiliarOSAnalyticsState {
readonly messagesSent: number;
readonly reactionsSent: number;
readonly reactionCounts: Record<FamiliarOSReaction, number>;
readonly perPetActivityCounts: Record<string, number>;
readonly lastActivityAt?: number;
}
export type FamiliarOSActivityRecord =
| { readonly kind: "say"; readonly reaction?: FamiliarOSReaction; readonly petId?: string }
| { readonly kind: "react"; readonly reaction: FamiliarOSReaction; readonly petId?: string };
export function createDefaultAnalyticsState(): FamiliarOSAnalyticsState {
return {
messagesSent: 0,
reactionsSent: 0,
reactionCounts: normalizeReactionCounts(undefined),
perPetActivityCounts: {},
lastActivityAt: undefined,
};
}
export function buildRecordedActivityAnalytics(
analytics: FamiliarOSAnalyticsState,
activity: FamiliarOSActivityRecord,
now: number = Date.now(),
): FamiliarOSAnalyticsState {
const reaction = activity.reaction;
const petId = activity.petId;
return {
messagesSent: analytics.messagesSent + (activity.kind === "say" ? 1 : 0),
reactionsSent: analytics.reactionsSent + (reaction ? 1 : 0),
reactionCounts: reaction
? { ...analytics.reactionCounts, [reaction]: (analytics.reactionCounts[reaction] ?? 0) + 1 }
: analytics.reactionCounts,
perPetActivityCounts: petId
? { ...analytics.perPetActivityCounts, [petId]: (analytics.perPetActivityCounts[petId] ?? 0) + 1 }
: analytics.perPetActivityCounts,
lastActivityAt: normalizeTimestamp(now) ?? Date.now(),
};
}
export function normalizeAnalytics(value: unknown): FamiliarOSAnalyticsState {
const record = isRecord(value) ? value : {};
return {
messagesSent: normalizeCount(record.messagesSent),
reactionsSent: normalizeCount(record.reactionsSent),
reactionCounts: normalizeReactionCounts(record.reactionCounts),
perPetActivityCounts: normalizePerPetActivityCounts(record.perPetActivityCounts),
lastActivityAt: normalizeTimestamp(record.lastActivityAt),
};
}
function normalizeReactionCounts(value: unknown): Record<FamiliarOSReaction, number> {
const record = isRecord(value) ? value : {};
const counts = {} as Record<FamiliarOSReaction, number>;
for (const reaction of allowedReactions) {
counts[reaction] = normalizeCount(record[reaction]);
}
return counts;
}
function normalizePerPetActivityCounts(value: unknown): Record<string, number> {
const record = isRecord(value) ? value : {};
const counts: Record<string, number> = {};
for (const [petId, rawCount] of Object.entries(record)) {
if (petId !== builtInPet.id && !isSafeInstalledPetId(petId)) {
continue;
}
const count = normalizeCount(rawCount);
if (count > 0) counts[petId] = count;
}
return counts;
}
function normalizeCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function normalizeTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
function isSafeInstalledPetId(petId: string): boolean {
return installedPetIdPattern.test(petId) && petId !== builtInPet.id;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -3,12 +3,11 @@ export interface OnboardingPreferenceLike {
}
export const petScaleOptions = [
{ label: "XXXS", value: 0.16 },
{ label: "XXS", value: 0.24 },
{ label: "XS", value: 0.32 },
{ label: "S", value: 0.44 },
{ label: "M", value: 0.56 },
{ label: "L", value: 0.72 },
{ label: "Small", value: 0.44 },
{ label: "Medium", value: 0.56 },
{ label: "Large", value: 0.72 },
{ label: "XL", value: 0.88 },
{ label: "XXL", value: 1.04 },
{ label: "XXXL", value: 1.20 },
@ -29,15 +28,7 @@ export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): b
return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false;
}
export function normalizeFamiliarName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (/[\r\n\0\x01-\x1f\x7f]/.test(value)) return undefined;
const trimmed = value.trim().slice(0, 64);
if (!trimmed) return undefined;
return trimmed;
}
export function markOnboardingCompleted<T extends { readonly preferences: OnboardingPreferenceLike }>(state: T): T {
export function markOnboardingCompleted<T extends { readonly preferences: Record<string, unknown> }>(state: T): T {
return {
...state,
preferences: {

View file

@ -1,103 +0,0 @@
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
export interface FamiliarStateSource {
readonly kind?: "catalog";
readonly catalogVersion: 2;
readonly zip: string;
readonly preview: string;
}
export interface CodexFamiliarStateSource {
readonly kind: "codex";
readonly path: string;
}
export interface FamiliarStateRecord {
readonly id: string;
readonly displayName: string;
readonly description?: string;
readonly builtIn: boolean;
readonly protected: boolean;
readonly installed: true;
readonly source?: FamiliarStateSource | CodexFamiliarStateSource;
readonly broken?: boolean;
readonly brokenReason?: string;
}
export function normalizeInstalledPetRecord(
value: unknown,
options: {
readonly builtInPetId: string;
readonly assertSafePetId: (petId: string) => void;
readonly validateInstalledPetFiles: (petId: string) => string | undefined;
},
): FamiliarStateRecord | null {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string") {
return null;
}
try {
options.assertSafePetId(value.id);
} catch {
return null;
}
const brokenReason = options.validateInstalledPetFiles(value.id);
return {
id: value.id,
displayName: value.displayName,
description: typeof value.description === "string" ? value.description : undefined,
builtIn: value.id === options.builtInPetId ? true : value.builtIn === true,
protected: value.id === options.builtInPetId ? true : value.protected === true,
installed: true,
source: normalizeFamiliarStateSource(value.source),
broken: brokenReason ? true : typeof value.broken === "boolean" ? value.broken : undefined,
brokenReason: brokenReason ?? (typeof value.brokenReason === "string" ? value.brokenReason : undefined),
};
}
export function normalizeFamiliarStateSource(value: unknown): FamiliarStateRecord["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,
};
}
export function validateInstalledPetFiles(
petId: string,
getInstalledPetDir: (petId: string) => string,
): string | undefined {
try {
const dir = getInstalledPetDir(petId);
const petJsonPath = join(dir, "familiar.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 familiar files are invalid.";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -1,139 +0,0 @@
import { builtInPet } from "./built-in-familiar.js";
import type { Point } from "./display.js";
import {
createDefaultAnalyticsState,
normalizeAnalytics,
type FamiliarOSActivityRecord,
type FamiliarOSAnalyticsState,
} from "./app-state-analytics.js";
import {
normalizeInstalledPetRecord,
validateInstalledPetFiles,
type FamiliarStateRecord,
} from "./app-state-familiar-records.js";
import {
createDefaultPreferences,
normalizePreferences,
type FamiliarOSPreferences,
} from "./app-state-preferences.js";
import { assertSafePetId, getInstalledPetDir } from "./familiar-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 FamiliarOSStateV1 {
readonly version: 1;
readonly preferences: FamiliarOSPreferences;
readonly familiars: {
readonly installed: readonly InstalledPetState[];
};
readonly defaultPet: {
readonly position?: Point;
};
readonly analytics: FamiliarOSAnalyticsState;
}
export function normalizeState(value: unknown): FamiliarOSStateV1 {
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((familiar) => familiar.id === preferencesRecord.defaultPetId && !familiar.broken)
? preferencesRecord.defaultPetId
: builtInPet.id;
return {
version: 1,
preferences: normalizePreferences({
...defaultState.preferences,
...preferencesRecord,
defaultPetId,
}),
familiars: {
installed: installedPets,
},
defaultPet: position ? { position } : {},
analytics: normalizeAnalytics(record.analytics),
};
}
export function createDefaultState(): FamiliarOSStateV1 {
return {
version: 1,
preferences: createDefaultPreferences(),
familiars: {
installed: [builtInPet],
},
defaultPet: {},
analytics: createDefaultAnalyticsState(),
};
}
export function cloneState(state: FamiliarOSStateV1): FamiliarOSStateV1 {
return structuredClone(state) as FamiliarOSStateV1;
}
export 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 normalizeInstalledPets(value: Record<string, unknown>): InstalledPetState[] {
const installed = isRecord(value.familiars) && Array.isArray(value.familiars.installed)
? value.familiars.installed
: [];
const normalized = installed
.map((familiar) => normalizeInstalledPetRecord(familiar, {
builtInPetId: builtInPet.id,
assertSafePetId,
validateInstalledPetFiles: (petId) => validateInstalledPetFiles(petId, getInstalledPetDir),
}))
.filter((familiar): familiar is FamiliarStateRecord => Boolean(familiar && familiar.id !== builtInPet.id));
return [builtInPet, ...normalized];
}
function normalizeMaybePosition(value: unknown): Point | undefined {
if (!isRecord(value)) {
return undefined;
}
return normalizePosition(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export type { FamiliarOSActivityRecord };

View file

@ -1,189 +0,0 @@
import { statSync } from "node:fs";
import { isAbsolute } from "node:path";
import { defaultPetScale, normalizeFamiliarName, normalizeOnboardingCompleted, normalizePetScale } from "./app-state-core.js";
import { builtInPet } from "./built-in-familiar.js";
import { isSupportedLocale, type LocalePreference } from "./i18n/catalog.js";
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
export interface FamiliarOSPreferences {
readonly defaultPetId: string;
readonly openDefaultPetOnLaunch: boolean;
readonly locale: LocalePreference;
readonly speechBubblesEnabled: boolean;
readonly petScale: number;
readonly openApiChatModel?: string;
readonly openApiChatSystemPrompt?: string;
readonly openApiChatEndpoint?: string;
readonly openApiChatTheme?: "system" | "light" | "dark";
readonly reactionAnimationOverrides?: ReactionAnimationOverrides;
readonly onboardingCompleted: boolean;
readonly claudeCommandPath?: string;
readonly nodeCommandPath?: string;
readonly opencodeCommandPath?: string;
readonly vanillaChatMcpTools?: readonly string[];
readonly openApiChatBaseInstructionsEnabled?: boolean;
readonly ttsProvider?: "system" | "openai" | "elevenlabs" | "piper" | "openai-compatible";
readonly ttsVoice?: string;
readonly ttsSpeed?: number;
readonly ttsModel?: string;
readonly ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom";
readonly ttsEndpoint?: string;
readonly familiarName?: string;
}
type LegacyOpenAiPreferences = Partial<{
readonly openAiChatModel?: unknown;
readonly openAiChatSystemPrompt?: unknown;
readonly openAiChatEndpoint?: unknown;
readonly openAiChatTheme?: unknown;
}>;
export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses";
const allowedTtsProviders = ["system", "openai", "elevenlabs", "piper", "openai-compatible"] as const;
const allowedTtsEndpointPresets = ["openrouter", "litellm", "wavespeedai", "custom"] as const;
export function createDefaultPreferences(): FamiliarOSPreferences {
return {
defaultPetId: builtInPet.id,
openDefaultPetOnLaunch: true,
locale: "system",
speechBubblesEnabled: true,
petScale: defaultPetScale,
openApiChatModel: undefined,
openApiChatSystemPrompt: undefined,
openApiChatEndpoint: undefined,
openApiChatTheme: "system",
reactionAnimationOverrides: undefined,
onboardingCompleted: false,
claudeCommandPath: undefined,
nodeCommandPath: undefined,
opencodeCommandPath: undefined,
vanillaChatMcpTools: undefined,
openApiChatBaseInstructionsEnabled: true,
ttsProvider: "system",
ttsVoice: undefined,
ttsSpeed: 1,
ttsModel: undefined,
ttsEndpointPreset: "openrouter",
ttsEndpoint: undefined,
familiarName: undefined,
};
}
export function normalizePreferences(value: Partial<FamiliarOSPreferences> & LegacyOpenAiPreferences): FamiliarOSPreferences {
const defaults = createDefaultPreferences();
const legacyValue = value as LegacyOpenAiPreferences;
return {
defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id,
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
? value.openDefaultPetOnLaunch
: defaults.openDefaultPetOnLaunch,
locale: normalizeLocalePreference(value.locale),
speechBubblesEnabled: true,
petScale: normalizePetScale(value.petScale),
openApiChatModel: normalizeOpenApiChatModel(value.openApiChatModel ?? legacyValue.openAiChatModel),
openApiChatSystemPrompt: normalizeOpenApiChatSystemPrompt(value.openApiChatSystemPrompt ?? legacyValue.openAiChatSystemPrompt),
openApiChatEndpoint: normalizeOpenApiChatEndpoint(value.openApiChatEndpoint ?? legacyValue.openAiChatEndpoint),
openApiChatTheme: normalizeOpenApiChatTheme(value.openApiChatTheme ?? legacyValue.openAiChatTheme),
reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides),
onboardingCompleted: normalizeOnboardingCompleted(value),
claudeCommandPath: normalizeCommandPath(value.claudeCommandPath),
nodeCommandPath: normalizeCommandPath(value.nodeCommandPath),
opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath),
vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools),
openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true,
ttsProvider: isAllowedTtsProvider(value.ttsProvider) ? value.ttsProvider : "system",
ttsVoice: typeof value.ttsVoice === "string" ? value.ttsVoice : undefined,
ttsSpeed: typeof value.ttsSpeed === "number" && value.ttsSpeed >= 0.5 && value.ttsSpeed <= 2 ? value.ttsSpeed : 1,
ttsModel: typeof value.ttsModel === "string" ? value.ttsModel : undefined,
ttsEndpointPreset: isAllowedTtsEndpointPreset(value.ttsEndpointPreset) ? value.ttsEndpointPreset : "openrouter",
ttsEndpoint: typeof value.ttsEndpoint === "string" ? value.ttsEndpoint : undefined,
familiarName: normalizeFamiliarName(value.familiarName),
};
}
export function normalizeOpenApiChatEndpoint(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 2_048 || /[\0\r\n]/.test(trimmed)) return undefined;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return undefined;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1") {
return undefined;
}
if (!url.hostname || url.username || url.password || url.search || url.hash) return undefined;
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/v1";
} else if (path.endsWith("/responses") || path.endsWith("/chat/completions")) {
url.pathname = path;
} else if (path.endsWith("/v1")) {
url.pathname = path;
} else {
return undefined;
}
const normalized = url.toString();
return normalized === defaultOpenApiChatEndpoint ? undefined : normalized;
}
function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter((candidate): candidate is string => typeof candidate === "string" && /^[a-z0-9-]+$/.test(candidate));
return valid.length > 0 ? valid : undefined;
}
function normalizeLocalePreference(value: unknown): LocalePreference {
if (value === "system") return "system";
return isSupportedLocale(value) ? value : "system";
}
function normalizeCommandPath(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 4_096 || /[\r\n\0]/.test(trimmed) || !isAbsolute(trimmed)) return undefined;
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) return undefined;
try {
if (!statSync(trimmed).isFile()) return undefined;
} catch {
return undefined;
}
return trimmed;
}
function normalizeOpenApiChatModel(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
return trimmed;
}
function normalizeOpenApiChatSystemPrompt(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.replaceAll("\r\n", "\n").trim();
if (!normalized || normalized.length > 8_000 || /[\0]/.test(normalized)) return undefined;
return normalized;
}
function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark" {
return value === "light" || value === "dark" ? value : "system";
}
function isAllowedTtsProvider(value: unknown): value is FamiliarOSPreferences["ttsProvider"] {
return typeof value === "string" && (allowedTtsProviders as readonly string[]).includes(value);
}
function isAllowedTtsEndpointPreset(value: unknown): value is FamiliarOSPreferences["ttsEndpointPreset"] {
return typeof value === "string" && (allowedTtsEndpointPresets as readonly string[]).includes(value);
}

View file

@ -1,98 +0,0 @@
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
const directInstallLockName = ".install-familiar.lock";
const directInstallLockStaleMs = 10 * 60 * 1000;
const legacyFileMigrations: Record<string, string> = {
"openpets-state.json": "familiaros-state.json",
"openpets-openapi-chat.json": "familiaros-openapi-chat.json",
"openpets-openai-chat.json": "familiaros-openai-chat.json",
"openpets-chat-history.json": "familiaros-chat-history.json",
"openpets-chat-conversations.json": "familiaros-chat-conversations.json",
"openpets-memory.json": "familiaros-memory.json",
"openpets-plugin-platform.json": "familiaros-plugin-platform.json",
"openpets-plugin-state.json": "familiaros-plugin-state.json",
"openpets-tts-credential.json": "familiaros-tts-credential.json",
};
export function migrateLegacyStateFilenames(userDataPath: string): void {
for (const [oldName, newName] of Object.entries(legacyFileMigrations)) {
const oldPath = join(userDataPath, oldName);
const newPath = join(userDataPath, newName);
if (existsSync(oldPath) && !existsSync(newPath)) {
try {
renameSync(oldPath, newPath);
console.log(`Migrated legacy state file: ${oldName} -> ${newName}`);
} catch (error) {
console.error(`Failed to migrate ${oldName}:`, error);
}
}
}
}
export function readStateJsonFile(path: string): unknown {
if (!existsSync(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
console.error(`Failed to read FamiliarOS state from ${path}; using defaults.`, error);
return undefined;
}
}
export function writeStateJsonFile(path: string, state: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
}
export function acquireStartupInstallLock(userDataPath: string): string {
mkdirSync(userDataPath, { recursive: true, mode: 0o700 });
const lockPath = join(userDataPath, directInstallLockName);
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
mkdirSync(lockPath, { mode: 0o700 });
writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), command: "familiaros-startup" })}\n`, "utf8");
return lockPath;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
if (code !== "EEXIST") throw error;
if (isStaleInstallLock(lockPath)) {
rmSync(lockPath, { recursive: true, force: true });
continue;
}
throw new Error("FamiliarOS cannot start while a direct familiar install is in progress. Wait for install-familiar to finish, then reopen FamiliarOS.");
}
}
throw new Error("Could not acquire FamiliarOS startup lock.");
}
function isStaleInstallLock(lockPath: string): boolean {
try {
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")) as { readonly pid?: unknown; readonly createdAt?: unknown };
if (typeof owner.createdAt === "number" && Date.now() - owner.createdAt > directInstallLockStaleMs) return true;
if (typeof owner.pid === "number" && owner.pid > 0) return !isProcessAlive(owner.pid);
} catch {
// Fall back to mtime for old/partial locks.
}
try {
return Date.now() - statSync(lockPath).mtimeMs > directInstallLockStaleMs;
} catch {
return true;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
return code === "EPERM";
}
}

View file

@ -1,54 +1,98 @@
import { rmSync } from "node:fs";
import { join } from "node:path";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { app } from "electron";
import { defaultPetScale, markOnboardingCompleted, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
import {
buildRecordedActivityAnalytics,
type FamiliarOSActivityRecord,
} from "./app-state-analytics.js";
import {
cloneState,
normalizeState,
normalizePosition,
type FamiliarOSStateV1,
type InstalledPetState,
} from "./app-state-normalization.js";
import { builtInPet } from "./built-in-familiar.js";
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 {
normalizePreferences,
} from "./app-state-preferences.js";
import { isSupportedLocale, type LocalePreference } from "./i18n/catalog.js";
import { allowedReactions, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { publishPluginAgentActivity } from "./plugin-events-source.js";
import {
acquireStartupInstallLock,
migrateLegacyStateFilenames,
readStateJsonFile,
writeStateJsonFile,
} from "./app-state-storage.js";
export { defaultOpenApiChatEndpoint, normalizeOpenApiChatEndpoint } from "./app-state-preferences.js";
export type { FamiliarOSActivityRecord, FamiliarOSAnalyticsState } from "./app-state-analytics.js";
export type { FamiliarOSStateV1, InstalledPetState } from "./app-state-normalization.js";
export type { FamiliarOSPreferences } from "./app-state-preferences.js";
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
export { defaultPetScale, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue };
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;
}
const stateFileName = "familiaros-state.json";
export interface OpenPetsStateV1 {
readonly version: 1;
readonly preferences: {
readonly defaultPetId: string;
readonly openDefaultPetOnLaunch: boolean;
readonly locale: LocalePreference;
readonly speechBubblesEnabled: boolean;
readonly petScale: number;
readonly openApiChatModel?: string;
readonly openApiChatSystemPrompt?: string;
readonly openApiChatEndpoint?: string;
readonly openApiChatTheme?: "system" | "light" | "dark";
readonly reactionAnimationOverrides?: ReactionAnimationOverrides;
readonly onboardingCompleted: boolean;
readonly claudeCommandPath?: string;
readonly nodeCommandPath?: string;
readonly opencodeCommandPath?: string;
readonly vanillaChatMcpTools?: readonly string[];
readonly openApiChatBaseInstructionsEnabled?: boolean;
};
readonly pets: {
readonly installed: readonly InstalledPetState[];
};
readonly defaultPet: {
readonly position?: Point;
};
readonly analytics: OpenPetsAnalyticsState;
}
export interface OpenPetsAnalyticsState {
readonly messagesSent: number;
readonly reactionsSent: number;
readonly reactionCounts: Record<OpenPetsReaction, number>;
readonly perPetActivityCounts: Record<string, number>;
readonly lastActivityAt?: number;
}
export type OpenPetsActivityRecord =
| { readonly kind: "say"; readonly reaction?: OpenPetsReaction; readonly petId?: string }
| { readonly kind: "react"; readonly reaction: OpenPetsReaction; readonly petId?: string };
export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue };
export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses";
const stateFileName = "openpets-state.json";
const directInstallLockName = ".install-pet.lock";
const directInstallLockStaleMs = 10 * 60 * 1000;
let statePath: string | null = null;
let currentState: FamiliarOSStateV1 | null = null;
let currentState: OpenPetsStateV1 | null = null;
let startupInstallLockPath: string | null = null;
export function initializeAppState(): void {
const userDataPath = app.getPath("userData");
migrateLegacyStateFilenames(userDataPath);
startupInstallLockPath = acquireStartupInstallLock(userDataPath);
statePath = join(userDataPath, stateFileName);
const nextState = normalizeState(readStateJsonFile(statePath));
const nextState = normalizeState(readStateFile(statePath));
writeStateToDisk(nextState);
currentState = nextState;
console.log(`FamiliarOS state initialized at ${statePath}.`);
console.log(`OpenPets state initialized at ${statePath}.`);
}
export function releaseStartupInstallLock(): void {
@ -57,11 +101,11 @@ export function releaseStartupInstallLock(): void {
if (lockPath) rmSync(lockPath, { recursive: true, force: true });
}
export function getAppStateSnapshot(): FamiliarOSStateV1 {
export function getAppStateSnapshot(): OpenPetsStateV1 {
return cloneState(getInitializedState());
}
export function updatePreferences(patch: Partial<FamiliarOSStateV1["preferences"]>): FamiliarOSStateV1 {
export function updatePreferences(patch: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1 {
const state = getInitializedState();
const preferences = normalizePreferences({ ...state.preferences, ...patch });
@ -78,23 +122,23 @@ export function isOnboardingCompleted(): boolean {
return getInitializedState().preferences.onboardingCompleted;
}
export function completeOnboarding(): FamiliarOSStateV1 {
export function completeOnboarding(): OpenPetsStateV1 {
const state = getInitializedState();
const nextState = normalizeState(markOnboardingCompleted(state));
commitState(nextState);
return getAppStateSnapshot();
}
export function setDefaultPet(defaultPetId: string): FamiliarOSStateV1 {
export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
const state = getInitializedState();
const targetPet = state.familiars.installed.find((familiar) => familiar.id === defaultPetId);
const targetPet = state.pets.installed.find((pet) => pet.id === defaultPetId);
if (!targetPet) {
throw new Error(`Cannot set unknown familiar as default: ${defaultPetId}`);
throw new Error(`Cannot set unknown pet as default: ${defaultPetId}`);
}
if (targetPet.broken) {
throw new Error(`Cannot set broken familiar as default: ${defaultPetId}`);
throw new Error(`Cannot set broken pet as default: ${defaultPetId}`);
}
const nextState = normalizeState({
@ -109,7 +153,7 @@ export function setDefaultPet(defaultPetId: string): FamiliarOSStateV1 {
return getAppStateSnapshot();
}
export function setDefaultPetPosition(position: Point): FamiliarOSStateV1 {
export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
const state = getInitializedState();
const nextState = normalizeState({
@ -124,7 +168,7 @@ export function setDefaultPetPosition(position: Point): FamiliarOSStateV1 {
return getAppStateSnapshot();
}
export function resetDefaultPetPosition(position: Point): FamiliarOSStateV1 {
export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 {
return setDefaultPetPosition(position);
}
@ -132,32 +176,45 @@ export function getDefaultPetPosition(): Point | undefined {
return getInitializedState().defaultPet.position;
}
export function recordFamiliarOSActivity(activity: FamiliarOSActivityRecord, now: number = Date.now()): FamiliarOSStateV1 {
export function recordOpenPetsActivity(activity: OpenPetsActivityRecord, now: number = Date.now()): OpenPetsStateV1 {
publishPluginAgentActivity({ kind: activity.kind, reaction: activity.reaction });
const state = getInitializedState();
const analytics = state.analytics;
const reaction = activity.kind === "react" ? activity.reaction : activity.reaction;
const petId = activity.petId;
const nextState = normalizeState({
...state,
analytics: buildRecordedActivityAnalytics(state.analytics, activity, now),
analytics: {
messagesSent: analytics.messagesSent + (activity.kind === "say" ? 1 : 0),
reactionsSent: analytics.reactionsSent + (reaction ? 1 : 0),
reactionCounts: reaction
? { ...analytics.reactionCounts, [reaction]: (analytics.reactionCounts[reaction] ?? 0) + 1 }
: analytics.reactionCounts,
perPetActivityCounts: petId
? { ...analytics.perPetActivityCounts, [petId]: (analytics.perPetActivityCounts[petId] ?? 0) + 1 }
: analytics.perPetActivityCounts,
lastActivityAt: normalizeTimestamp(now) ?? Date.now(),
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function installPetState(familiar: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): FamiliarOSStateV1 {
export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): OpenPetsStateV1 {
const state = getInitializedState();
if (state.familiars.installed.some((installedPet) => installedPet.id === familiar.id)) {
throw new Error(`Familiar is already installed: ${familiar.id}`);
if (state.pets.installed.some((installedPet) => installedPet.id === pet.id)) {
throw new Error(`Pet is already installed: ${pet.id}`);
}
const nextState = normalizeState({
...state,
familiars: {
pets: {
installed: [
...state.familiars.installed,
...state.pets.installed,
{
...familiar,
...pet,
builtIn: false,
protected: false,
installed: true,
@ -170,16 +227,16 @@ export function installPetState(familiar: Omit<InstalledPetState, "builtIn" | "p
return getAppStateSnapshot();
}
export function removePetState(petId: string): FamiliarOSStateV1 {
export function removePetState(petId: string): OpenPetsStateV1 {
if (petId === builtInPet.id) {
throw new Error("Built-in familiar cannot be removed.");
throw new Error("Built-in pet cannot be removed.");
}
const state = getInitializedState();
const existing = state.familiars.installed.find((familiar) => familiar.id === petId);
const existing = state.pets.installed.find((pet) => pet.id === petId);
if (!existing) {
throw new Error(`Familiar is not installed: ${petId}`);
throw new Error(`Pet is not installed: ${petId}`);
}
const nextDefaultPetId = state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId;
@ -190,8 +247,8 @@ export function removePetState(petId: string): FamiliarOSStateV1 {
...state.preferences,
defaultPetId: nextDefaultPetId,
},
familiars: {
installed: state.familiars.installed.filter((familiar) => familiar.id !== petId),
pets: {
installed: state.pets.installed.filter((pet) => pet.id !== petId),
},
});
@ -199,7 +256,7 @@ export function removePetState(petId: string): FamiliarOSStateV1 {
return getAppStateSnapshot();
}
export function markPetBroken(petId: string, brokenReason: string): FamiliarOSStateV1 {
export function markPetBroken(petId: string, brokenReason: string): OpenPetsStateV1 {
const state = getInitializedState();
if (petId === builtInPet.id) {
@ -212,8 +269,8 @@ export function markPetBroken(petId: string, brokenReason: string): FamiliarOSSt
...state.preferences,
defaultPetId: state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId,
},
familiars: {
installed: state.familiars.installed.map((familiar) => familiar.id === petId ? { ...familiar, broken: true, brokenReason } : familiar),
pets: {
installed: state.pets.installed.map((pet) => pet.id === petId ? { ...pet, broken: true, brokenReason } : pet),
},
});
@ -223,25 +280,406 @@ export function markPetBroken(petId: string, brokenReason: string): FamiliarOSSt
export function getStateFilePath(): string {
if (!statePath) {
throw new Error("FamiliarOS app state has not been initialized.");
throw new Error("OpenPets app state has not been initialized.");
}
return statePath;
}
function getInitializedState(): FamiliarOSStateV1 {
function getInitializedState(): OpenPetsStateV1 {
if (!currentState) {
throw new Error("FamiliarOS app state has not been initialized.");
throw new Error("OpenPets app state has not been initialized.");
}
return currentState;
}
function commitState(nextState: FamiliarOSStateV1): void {
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 } : {},
analytics: normalizeAnalytics(record.analytics),
};
}
function normalizeAnalytics(value: unknown): OpenPetsAnalyticsState {
const record = isRecord(value) ? value : {};
return {
messagesSent: normalizeCount(record.messagesSent),
reactionsSent: normalizeCount(record.reactionsSent),
reactionCounts: normalizeReactionCounts(record.reactionCounts),
perPetActivityCounts: normalizePerPetActivityCounts(record.perPetActivityCounts),
lastActivityAt: normalizeTimestamp(record.lastActivityAt),
};
}
function normalizeReactionCounts(value: unknown): Record<OpenPetsReaction, number> {
const record = isRecord(value) ? value : {};
const counts = {} as Record<OpenPetsReaction, number>;
for (const reaction of allowedReactions) {
counts[reaction] = normalizeCount(record[reaction]);
}
return counts;
}
function normalizePerPetActivityCounts(value: unknown): Record<string, number> {
const record = isRecord(value) ? value : {};
const counts: Record<string, number> = {};
for (const [petId, rawCount] of Object.entries(record)) {
if (petId !== builtInPet.id) {
try {
assertSafePetId(petId);
} catch {
continue;
}
}
const count = normalizeCount(rawCount);
if (count > 0) counts[petId] = count;
}
return counts;
}
function normalizeCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function normalizeTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1["preferences"] {
const defaultState = createDefaultState();
const legacyValue = value as Partial<{
readonly openAiChatModel?: unknown;
readonly openAiChatSystemPrompt?: unknown;
readonly openAiChatEndpoint?: unknown;
readonly openAiChatTheme?: unknown;
}>;
return {
defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id,
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
? value.openDefaultPetOnLaunch
: defaultState.preferences.openDefaultPetOnLaunch,
locale: normalizeLocalePreference(value.locale),
speechBubblesEnabled: true,
petScale: normalizePetScale(value.petScale),
openApiChatModel: normalizeOpenApiChatModel(value.openApiChatModel ?? legacyValue.openAiChatModel),
openApiChatSystemPrompt: normalizeOpenApiChatSystemPrompt(value.openApiChatSystemPrompt ?? legacyValue.openAiChatSystemPrompt),
openApiChatEndpoint: normalizeOpenApiChatEndpoint(value.openApiChatEndpoint ?? legacyValue.openAiChatEndpoint),
openApiChatTheme: normalizeOpenApiChatTheme(value.openApiChatTheme ?? legacyValue.openAiChatTheme),
reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides),
onboardingCompleted: normalizeOnboardingCompleted(value),
claudeCommandPath: normalizeCommandPath(value.claudeCommandPath),
nodeCommandPath: normalizeCommandPath(value.nodeCommandPath),
opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath),
vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools),
openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true,
};
}
function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter((v): v is string => typeof v === "string" && /^[a-z0-9-]+$/.test(v));
return valid.length > 0 ? valid : undefined;
}
function normalizeLocalePreference(value: unknown): LocalePreference {
if (value === "system") return "system";
return isSupportedLocale(value) ? value : "system";
}
function normalizeCommandPath(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 4096 || /[\r\n\0]/.test(trimmed) || !isAbsolute(trimmed)) return undefined;
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) return undefined;
try {
if (!statSync(trimmed).isFile()) return undefined;
} catch {
return undefined;
}
return trimmed;
}
function normalizeOpenApiChatModel(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
return trimmed;
}
function normalizeOpenApiChatSystemPrompt(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.replaceAll("\r\n", "\n").trim();
if (!normalized || normalized.length > 8_000 || /[\0]/.test(normalized)) return undefined;
return normalized;
}
export function normalizeOpenApiChatEndpoint(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 2_048 || /[\0\r\n]/.test(trimmed)) return undefined;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return undefined;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1") {
return undefined;
}
if (!url.hostname || url.username || url.password || url.search || url.hash) return undefined;
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/v1";
} else if (path.endsWith("/responses") || path.endsWith("/chat/completions")) {
url.pathname = path;
} else if (path.endsWith("/v1")) {
url.pathname = path;
} else {
return undefined;
}
const normalized = url.toString();
return normalized === defaultOpenApiChatEndpoint ? undefined : normalized;
}
function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark" {
return value === "light" || value === "dark" ? value : "system";
}
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,
locale: "system",
speechBubblesEnabled: true,
petScale: defaultPetScale,
openApiChatModel: undefined,
openApiChatSystemPrompt: undefined,
openApiChatEndpoint: undefined,
openApiChatTheme: "system",
reactionAnimationOverrides: undefined,
onboardingCompleted: false,
claudeCommandPath: undefined,
nodeCommandPath: undefined,
opencodeCommandPath: undefined,
vanillaChatMcpTools: undefined,
openApiChatBaseInstructionsEnabled: true,
},
pets: {
installed: [builtInPet],
},
defaultPet: {},
analytics: {
messagesSent: 0,
reactionsSent: 0,
reactionCounts: normalizeReactionCounts(undefined),
perPetActivityCounts: {},
lastActivityAt: undefined,
},
};
}
function commitState(nextState: OpenPetsStateV1): void {
writeStateToDisk(nextState);
currentState = nextState;
}
function writeStateToDisk(state: FamiliarOSStateV1): void {
writeStateJsonFile(getStateFilePath(), state);
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 acquireStartupInstallLock(userDataPath: string): string {
mkdirSync(userDataPath, { recursive: true, mode: 0o700 });
const lockPath = join(userDataPath, directInstallLockName);
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
mkdirSync(lockPath, { mode: 0o700 });
writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), command: "openpets-startup" })}\n`, "utf8");
return lockPath;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
if (code !== "EEXIST") throw error;
if (isStaleInstallLock(lockPath)) {
rmSync(lockPath, { recursive: true, force: true });
continue;
}
throw new Error("OpenPets cannot start while a direct pet install is in progress. Wait for install-pet to finish, then reopen OpenPets.");
}
}
throw new Error("Could not acquire OpenPets startup lock.");
}
function isStaleInstallLock(lockPath: string): boolean {
try {
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")) as { readonly pid?: unknown; readonly createdAt?: unknown };
if (typeof owner.createdAt === "number" && Date.now() - owner.createdAt > directInstallLockStaleMs) return true;
if (typeof owner.pid === "number" && owner.pid > 0) return !isProcessAlive(owner.pid);
} catch {
// Fall back to mtime for old/partial locks.
}
try {
return Date.now() - statSync(lockPath).mtimeMs > directInstallLockStaleMs;
} catch {
return true;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
return code === "EPERM";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -17,7 +17,7 @@ export function createAppIcon(): NativeImage {
return cachedAppIcon;
}
console.error(`FamiliarOS app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`OpenPets app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
cachedAppIcon = createFallbackTrayIcon();
return cachedAppIcon;
}
@ -30,7 +30,7 @@ export function createTrayIcon(): NativeImage {
return assetImage.resize({ width: 22, height: 22 });
}
console.error(`FamiliarOS tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`OpenPets tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
return createFallbackTrayIcon();
}
@ -63,7 +63,7 @@ function createFallbackTrayIcon(): NativeImage {
});
if (image.isEmpty()) {
console.error("FamiliarOS tray icon creation produced an empty image.");
console.error("OpenPets tray icon creation produced an empty image.");
}
if (process.platform === "darwin") {

View file

@ -1,6 +1,6 @@
export const builtInPet = {
id: "builtin",
displayName: "Built-in Familiar",
displayName: "Built-in Pet",
builtIn: true,
protected: true,
installed: true,

View file

@ -1,195 +0,0 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
type CatalogLoadResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const maxCatalogV3PageBytes = 256_000;
const fetchTimeoutMs = 5_000;
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
let v2CatalogPromise: Promise<CatalogV2> | null = null;
export async function tryLoadRemoteCatalogV3Index(): Promise<CatalogLoadResult<CatalogV3Index>> {
try {
return { ok: true, value: await getRemoteCatalogV3Index() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<CatalogLoadResult<readonly CatalogPetV2[]>> {
try {
return { ok: true, value: await getRemoteCatalogV3Page(page, index) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadRemoteCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
try {
return { ok: true, value: await getRemoteCatalogV2() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadFixtureCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
try {
return { ok: true, value: validateCatalogV2(await loadFixtureCatalog()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
return await v3IndexPromise;
}
export async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
const cached = v3PageCache.get(page);
if (cached) return cached;
const pageUrl = index.pages[page];
if (!pageUrl) throw new Error("Catalog page is out of range.");
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
const familiars = payload.familiars.map(toCatalogPetV2Compat);
assertUniquePetIds(familiars);
v3PageCache.set(page, familiars);
return familiars;
}
export async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
v3SearchPromise ||= Promise.resolve().then(async () => {
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
const pages = await Promise.all(
searchIndex.pages.map(async (pageUrl, page) =>
validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length),
),
);
const familiars = pages.flatMap((page) => page.familiars);
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return familiars;
});
return await v3SearchPromise;
}
export async function getV2CatalogOrFixture(): Promise<CatalogV2> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) return remote.value;
const fixture = await tryLoadFixtureCatalog();
if (fixture.ok) return fixture.value;
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
}
function toCatalogPetV2Compat(familiar: {
readonly id: string;
readonly displayName: string;
readonly description: string;
readonly thumbnail: string;
readonly spritesheet: string;
readonly zip: string;
readonly category: "western" | "asian";
readonly subcategory?: string;
readonly original?: boolean;
readonly featured?: boolean;
}): CatalogPetV2 {
const entry: CatalogPetV2 = {
id: familiar.id,
displayName: familiar.displayName,
description: familiar.description,
preview: familiar.thumbnail,
spritesheet: familiar.spritesheet,
zip: familiar.zip,
category: familiar.category,
};
return {
...entry,
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
...(familiar.original === undefined ? {} : { original: familiar.original }),
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
};
}
async function getRemoteCatalogV2(): Promise<CatalogV2> {
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
return await v2CatalogPromise;
}
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateCatalogEndpoint(response.url, url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await readLimitedResponse(response, maxBytes);
} finally {
clearTimeout(timeout);
}
}
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, maxBytes: number): 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 > maxBytes) 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, expected: string): void {
const url = new URL(value);
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
}
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
const ids = new Set<string>();
for (const familiar of familiars) {
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
ids.add(familiar.id);
}
}

View file

@ -1,21 +0,0 @@
export interface SurfaceableCatalogPet {
readonly original?: boolean;
readonly featured?: boolean;
}
export function isSurfaceablePet(familiar: SurfaceableCatalogPet): boolean {
return familiar.original === true || familiar.featured === true;
}
export function filterSurfaceablePets<T extends SurfaceableCatalogPet>(familiars: readonly T[]): readonly T[] {
return familiars.filter(isSurfaceablePet);
}
export function summarizeSurfaceablePets<T extends SurfaceableCatalogPet>(familiars: readonly T[], pageSize: number): { readonly total: number; readonly pageCount: number } {
if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error("Surfaceable page size must be a positive integer.");
const total = filterSurfaceablePets(familiars).length;
return {
total,
pageCount: Math.ceil(total / pageSize),
};
}

View file

@ -1,7 +1,7 @@
export interface CatalogV2 {
readonly version: 2;
readonly generatedAt: string;
readonly familiars: readonly CatalogPetV2[];
readonly pets: readonly CatalogPetV2[];
}
export interface CatalogPetV2 {
@ -41,7 +41,7 @@ export interface CatalogV3Page {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly familiars: readonly CatalogPetV3[];
readonly pets: readonly CatalogPetV3[];
}
export interface CatalogV3SearchIndex {
@ -56,7 +56,7 @@ export interface CatalogV3SearchPage {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly familiars: readonly CatalogV3SearchPet[];
readonly pets: readonly CatalogV3SearchPet[];
}
export interface CatalogV3SearchPet {
@ -86,16 +86,16 @@ 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.familiars)) throw new Error("Catalog familiars must be an array.");
if (value.familiars.length > 1000) throw new Error("Catalog has too many familiars.");
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 familiars = value.familiars.map((familiar) => validateCatalogPet(familiar, ids));
const pets = value.pets.map((pet) => validateCatalogPet(pet, ids));
return {
version: 2,
generatedAt: value.generatedAt,
familiars,
pets,
};
}
@ -133,16 +133,16 @@ export function validateCatalogV3Page(value: unknown, expectedPage: number): Cat
if (value.version !== 3) throw new Error("Catalog v3 page version must be 3.");
if (value.page !== expectedPage) throw new Error("Catalog v3 page index mismatch.");
const pageSize = validateInteger(value.pageSize, "Catalog v3 pageSize", 1, 500);
if (!Array.isArray(value.familiars)) throw new Error("Catalog v3 familiars must be an array.");
if (value.familiars.length > pageSize) throw new Error("Catalog v3 page has too many familiars.");
if (!Array.isArray(value.pets)) throw new Error("Catalog v3 pets must be an array.");
if (value.pets.length > pageSize) throw new Error("Catalog v3 page has too many pets.");
const ids = new Set<string>();
const familiars = value.familiars.map((familiar) => validateCatalogV3Pet(familiar, ids));
const pets = value.pets.map((pet) => validateCatalogV3Pet(pet, ids));
return {
version: 3,
page: expectedPage,
pageSize,
familiars,
pets,
};
}
@ -169,40 +169,36 @@ export function validateCatalogV3SearchPage(value: unknown, expectedPage: number
if (value.version !== 3) throw new Error("Catalog v3 search page version must be 3.");
if (value.page !== expectedPage) throw new Error("Catalog v3 search page index mismatch.");
const pageSize = validateInteger(value.pageSize, "Catalog v3 search pageSize", 1, 1000);
if (!Array.isArray(value.familiars)) throw new Error("Catalog v3 search familiars must be an array.");
if (value.familiars.length > pageSize) throw new Error("Catalog v3 search page has too many familiars.");
if (!Array.isArray(value.pets)) throw new Error("Catalog v3 search pets must be an array.");
if (value.pets.length > pageSize) throw new Error("Catalog v3 search page has too many pets.");
return {
version: 3,
page: expectedPage,
pageSize,
familiars: value.familiars.map((familiar) => validateCatalogV3SearchPet(familiar, catalogPageCount)),
pets: value.pets.map((pet) => validateCatalogV3SearchPet(pet, catalogPageCount)),
};
}
function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
if (!isRecord(value)) throw new Error("Catalog familiar must be an object.");
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 familiar id: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog pet id: ${id}`);
ids.add(id);
const entry: CatalogPetV2 = {
return {
id,
displayName: validateString(value.displayName, "displayName", 120),
description: validateString(value.description, "description", 500),
preview: validateCatalogUrl(value.preview, "preview"),
zip: validateCatalogUrl(value.zip, "zip"),
...(value.spritesheet === undefined ? {} : { spritesheet: validateCatalogUrl(value.spritesheet, "preview") }),
...(value.category === undefined ? {} : { category: validateCategory(value.category) }),
...(value.subcategory === undefined ? {} : { subcategory: validateString(value.subcategory, "subcategory", 80) }),
};
return withCatalogMeta(entry, value);
}
function validateCatalogV3Pet(value: unknown, ids: Set<string>): CatalogPetV3 {
if (!isRecord(value)) throw new Error("Catalog v3 familiar must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 pet must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 familiar id on page: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 pet id on page: ${id}`);
ids.add(id);
const category = validateCategory(value.category);
const entry: CatalogPetV3 = {
@ -228,7 +224,7 @@ function validateCatalogV3Category(value: unknown): CatalogV3Category {
}
function validateCatalogV3SearchPet(value: unknown, catalogPageCount: number): CatalogV3SearchPet {
if (!isRecord(value)) throw new Error("Catalog v3 search familiar must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 search pet must be an object.");
const catalogPage = validateInteger(value.catalogPage, "Catalog v3 search catalogPage", 0, Math.max(0, catalogPageCount - 1));
return withCatalogMeta({
id: validateId(value.id),
@ -248,7 +244,7 @@ function withCatalogMeta<T extends object>(entry: T, value: Record<string, unkno
}
function validateBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new Error(`Catalog familiar ${field} must be a boolean.`);
if (typeof value !== "boolean") throw new Error(`Catalog pet ${field} must be a boolean.`);
return value;
}
@ -267,15 +263,15 @@ function validateInteger(value: unknown, field: string, min: number, max: number
}
function validateId(value: unknown): string {
if (typeof value !== "string") throw new Error("Catalog familiar id must be a string.");
if (value === "builtin") throw new Error("Catalog familiar id 'builtin' is reserved.");
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog familiar id: ${value}`);
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 familiar ${field} must be a string.`);
if (value.length > maxLength) throw new Error(`Catalog familiar ${field} is too long.`);
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;
}
@ -288,10 +284,10 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "catalog"
if (url.port) throw new Error(`${field} URL cannot include a custom port.`);
if (field === "preview") {
if (url.hostname !== "familiaros.dev" || !url.pathname.startsWith("/familiars/")) throw new Error("Preview URL host/path is not allowed.");
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed.");
} else if (field === "catalog") {
if (url.hostname !== "familiaros.dev" || !url.pathname.startsWith("/familiars/catalog.v3/")) throw new Error("Catalog URL host/path is not allowed.");
} else if (url.hostname !== "zip.familiaros.dev" || !url.pathname.startsWith("/familiars/")) {
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/catalog.v3/")) throw new Error("Catalog 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.");
}

View file

@ -1,12 +1,20 @@
import { filterSurfaceablePets, isSurfaceablePet, summarizeSurfaceablePets } from "./catalog-surfaceable.js";
import { getRemoteCatalogV3Page, getRemoteCatalogV3Search, getV2CatalogOrFixture, tryLoadFixtureCatalog, tryLoadRemoteCatalog, tryLoadRemoteCatalogV3Index } from "./catalog-remote.js";
import type { CatalogPetV2, CatalogV3Index, CatalogV3SearchPet } from "./catalog-validation.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export { catalogUrl, catalogV3Url } from "./catalog-remote.js";
import { app } from "electron";
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json";
export const catalogV3Url = "https://openpets.dev/pets/catalog.v3.json";
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const maxCatalogV3PageBytes = 256_000;
const fetchTimeoutMs = 5_000;
export interface CatalogUiState {
readonly source: "remote" | "fixture" | "error";
readonly familiars: readonly CatalogPetV2[];
readonly pets: readonly CatalogPetV2[];
readonly generatedAt?: string;
readonly error?: string;
readonly version?: 2 | 3;
@ -21,30 +29,34 @@ export interface CatalogUiState {
export interface CatalogSearchUiState {
readonly source: "remote" | "error";
readonly familiars: readonly CatalogV3SearchPet[];
readonly pets: readonly CatalogV3SearchPet[];
readonly total?: number;
readonly error?: string;
}
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
let v2CatalogPromise: Promise<CatalogV2> | null = null;
export async function getCatalogUiState(): Promise<CatalogUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (remoteV3.ok) {
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value);
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index);
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
return {
source: "remote",
familiars: filterSurfaceablePets(firstPage.familiars),
generatedAt: remoteV3.value.generatedAt,
pets: filterSurfaceablePets(firstPage.pets),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: surfaceableStats.total,
categories: remoteV3.value.filters.categories,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
page: 0,
pageCount: surfaceableStats.pageCount,
pageCount: surfaceablePageCount(remoteV3.index),
supportsCategories: true,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
};
}
@ -54,36 +66,35 @@ export async function getCatalogUiState(): Promise<CatalogUiState> {
export async function getCatalogPageUiState(page: number): Promise<CatalogUiState> {
if (!Number.isInteger(page) || page < 0) throw new Error("Catalog page must be a non-negative integer.");
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
if (page >= surfaceableStats.pageCount) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.value);
if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error };
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
if (page >= surfaceablePageCount(remoteV3.index)) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index);
if (!pageResult.ok) return { source: "error", pets: [], error: pageResult.error };
return {
source: "remote",
familiars: filterSurfaceablePets(pageResult.familiars),
generatedAt: remoteV3.value.generatedAt,
pets: filterSurfaceablePets(pageResult.pets),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: surfaceableStats.total,
categories: remoteV3.value.filters.categories,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
page,
pageCount: surfaceableStats.pageCount,
pageCount: surfaceablePageCount(remoteV3.index),
supportsCategories: true,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
};
}
export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
try {
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value);
return { source: "remote", familiars: surfacedPets, total: surfacedPets.length };
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
return { source: "remote", pets: surfacedPets, total: surfacedPets.length };
} catch (error) {
return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" };
return { source: "error", pets: [], error: error instanceof Error ? error.message : "unknown error" };
}
}
@ -92,39 +103,40 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
if (remoteV3.ok) {
let blockedHiddenV3Pet = false;
try {
const searchPets = await getRemoteCatalogV3Search(remoteV3.value);
const searchPet = searchPets.find((familiar) => familiar.id === petId);
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
const searchPet = searchPets.find((pet) => pet.id === petId);
if (searchPet && !isSurfaceablePet(searchPet)) {
blockedHiddenV3Pet = true;
throw new Error(`Familiar is not available in the curated catalog: ${petId}`);
throw new Error(`Pet is not available in the curated catalog: ${petId}`);
}
if (searchPet) {
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value);
const familiar = page.find((candidate) => candidate.id === petId);
if (familiar && isSurfaceablePet(familiar)) return familiar;
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
const pet = page.find((candidate) => candidate.id === petId);
if (pet && isSurfaceablePet(pet)) return pet;
}
} catch (error) {
if (blockedHiddenV3Pet) throw error;
// Fall through to v2/fixture so visible v2-compatible familiars remain installable during partial v3 outages.
// Fall through to v2/fixture so visible v2-compatible pets remain installable during partial v3 outages.
}
}
const catalog = await getV2CatalogOrFixture();
const familiar = filterSurfaceablePets(catalog.familiars).find((candidate) => candidate.id === petId);
if (!familiar) throw new Error(`Familiar is not available in the validated catalog: ${petId}`);
return familiar;
const pet = filterSurfaceablePets(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 getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<CatalogUiState> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) {
return {
source: "remote",
familiars: filterSurfaceablePets(remote.value.familiars),
generatedAt: remote.value.generatedAt,
pets: filterSurfaceablePets(remote.catalog.pets),
generatedAt: remote.catalog.generatedAt,
version: 2,
total: filterSurfaceablePets(remote.value.familiars).length,
total: filterSurfaceablePets(remote.catalog.pets).length,
supportsCategories: false,
};
}
@ -134,39 +146,213 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (fixture.ok) {
return {
source: "fixture",
familiars: filterSurfaceablePets(fixture.value.familiars),
generatedAt: fixture.value.generatedAt,
pets: filterSurfaceablePets(fixture.catalog.pets),
generatedAt: fixture.catalog.generatedAt,
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
version: 2,
total: filterSurfaceablePets(fixture.value.familiars).length,
total: filterSurfaceablePets(fixture.catalog.pets).length,
supportsCategories: false,
};
}
return {
source: "error",
familiars: [],
pets: [],
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
};
}
function getSurfaceableSearchPets(familiars: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(familiars).map((familiar, surfaceIndex) => ({
...familiar,
function filterSurfaceablePets<T extends { readonly original?: boolean; readonly featured?: boolean }>(pets: readonly T[]): readonly T[] {
return pets.filter(isSurfaceablePet);
}
function isSurfaceablePet(pet: { readonly original?: boolean; readonly featured?: boolean }): boolean {
return pet.original === true || pet.featured === true;
}
function getSurfaceableSearchPets(pets: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(pets).map((pet, surfaceIndex) => ({
...pet,
catalogPage: Math.floor(surfaceIndex / index.pageSize),
}));
}
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
function surfaceableTotal(index: CatalogV3Index): number {
return (index.filters.originalsCount ?? 0) + (index.filters.featuredCount ?? 0);
}
function surfaceablePageCount(index: CatalogV3Index): number {
return Math.ceil(surfaceableTotal(index) / index.pageSize);
}
async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; readonly index: CatalogV3Index } | { readonly ok: false; readonly error: string }> {
try {
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
const pageSearchPets = searchPets.slice(page * index.pageSize, (page + 1) * index.pageSize);
const ids = new Set(pageSearchPets.map((familiar) => familiar.id));
const catalogPageNumbers = [...new Set(pageSearchPets.map((familiar) => familiar.catalogPage))];
const catalogPages = await Promise.all(catalogPageNumbers.map((catalogPage) => getRemoteCatalogV3Page(catalogPage, index)));
const petsById = new Map(catalogPages.flat().filter((familiar) => ids.has(familiar.id) && isSurfaceablePet(familiar)).map((familiar) => [familiar.id, familiar]));
return { ok: true, familiars: pageSearchPets.map((familiar) => petsById.get(familiar.id)).filter((familiar): familiar is CatalogPetV2 => Boolean(familiar)) };
const index = await getRemoteCatalogV3Index();
return { ok: true, index };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, pets: await getRemoteCatalogV3Page(page, index) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
const pageSearchPets = searchPets.slice(page * index.pageSize, (page + 1) * index.pageSize);
const ids = new Set(pageSearchPets.map((pet) => pet.id));
const catalogPageNumbers = [...new Set(pageSearchPets.map((pet) => pet.catalogPage))];
const catalogPages = await Promise.all(catalogPageNumbers.map((catalogPage) => getRemoteCatalogV3Page(catalogPage, index)));
const petsById = new Map(catalogPages.flat().filter((pet) => ids.has(pet.id) && isSurfaceablePet(pet)).map((pet) => [pet.id, pet]));
return { ok: true, pets: pageSearchPets.map((pet) => petsById.get(pet.id)).filter((pet): pet is CatalogPetV2 => Boolean(pet)) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
return await v3IndexPromise;
}
async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
const cached = v3PageCache.get(page);
if (cached) return cached;
const pageUrl = index.pages[page];
if (!pageUrl) throw new Error("Catalog page is out of range.");
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
const pets = payload.pets.map(toCatalogPetV2Compat);
assertUniquePetIds(pets);
v3PageCache.set(page, pets);
return pets;
}
async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
v3SearchPromise ||= Promise.resolve().then(async () => {
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
const pages = await Promise.all(searchIndex.pages.map(async (pageUrl, page) => validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length)));
const pets = pages.flatMap((page) => page.pets);
if (pets.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return pets;
});
return await v3SearchPromise;
}
function toCatalogPetV2Compat(pet: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 {
const entry: CatalogPetV2 = {
id: pet.id,
displayName: pet.displayName,
description: pet.description,
preview: pet.thumbnail,
spritesheet: pet.spritesheet,
zip: pet.zip,
category: pet.category,
};
return {
...entry,
...(pet.subcategory ? { subcategory: pet.subcategory } : {}),
...(pet.original === undefined ? {} : { original: pet.original }),
...(pet.featured === undefined ? {} : { featured: pet.featured }),
};
}
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, catalog: await getRemoteCatalogV2() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function getRemoteCatalogV2(): Promise<CatalogV2> {
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
return await v2CatalogPromise;
}
async function getV2CatalogOrFixture(): Promise<CatalogV2> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) return remote.catalog;
const fixture = await tryLoadFixtureCatalog();
if (fixture.ok) return fixture.catalog;
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
}
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateCatalogEndpoint(response.url, url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await readLimitedResponse(response, maxBytes);
} 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, maxBytes: number): 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 > maxBytes) 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, expected: string): void {
const url = new URL(value);
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
}
function assertUniquePetIds(pets: readonly CatalogPetV2[]): void {
const ids = new Set<string>();
for (const pet of pets) {
if (ids.has(pet.id)) throw new Error(`Duplicate catalog v3 pet id: ${pet.id}`);
ids.add(pet.id);
}
}

View file

@ -4,9 +4,9 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
// Desktop-specific Cursor integration checks
// These verify that the desktop app correctly uses the @familiaros/cursor package
// These verify that the desktop app correctly uses the @open-pets/cursor package
const root = realpathSync(mkdtempSync(join(tmpdir(), "familiaros-cursor-desktop-")));
const root = realpathSync(mkdtempSync(join(tmpdir(), "openpets-cursor-desktop-")));
try {
// Test that desktop would use the correct global config path
@ -29,10 +29,10 @@ try {
const configPath = join(cursorDir, "mcp.json");
const installedConfig = {
mcpServers: {
familiaros: {
openpets: {
type: "stdio",
command: "npx",
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
},
},
};
@ -40,12 +40,12 @@ try {
const content = readFileSync(configPath, "utf8");
const parsed = JSON.parse(content);
assert.deepEqual(parsed.mcpServers.familiaros, installedConfig.mcpServers.familiaros);
assert.deepEqual(parsed.mcpServers.openpets, installedConfig.mcpServers.openpets);
// Test that desktop would preserve unrelated servers during operations
const multiServerConfig = {
mcpServers: {
familiaros: installedConfig.mcpServers.familiaros,
openpets: installedConfig.mcpServers.openpets,
other: { type: "stdio", command: "test", args: [] },
},
topLevelField: "preserve",
@ -61,7 +61,7 @@ try {
const publishedEntry = {
type: "stdio",
command: "npx",
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "test"],
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "test"],
};
assert.equal(publishedEntry.command, "npx");
assert.ok(publishedEntry.args[1].includes("@"), "Published mode must use pinned version");
@ -69,7 +69,7 @@ try {
const localEntry = {
type: "stdio",
command: "node",
args: ["/absolute/path/to/mcp.js", "--familiar", "test"],
args: ["/absolute/path/to/mcp.js", "--pet", "test"],
};
assert.equal(localEntry.command, "node");
assert.ok(localEntry.args[0].startsWith("/"), "Local mode must use absolute path");
@ -113,24 +113,24 @@ try {
// Test that desktop would create valid MCP preview
const preview = {
mcpServers: {
familiaros: {
openpets: {
type: "stdio",
command: "npx",
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
},
},
};
assert.equal(preview.mcpServers.familiaros.type, "stdio");
assert.equal(preview.mcpServers.familiaros.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.familiaros.args));
assert.equal(preview.mcpServers.openpets.type, "stdio");
assert.equal(preview.mcpServers.openpets.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.openpets.args));
// Test desktop Phase 2 rules posture: preview/copy only, no project writes
const rulesPreview = {
rulesPath: ".cursor/rules/familiaros.mdc",
rulesContent: "<!-- FAMILIAROS:CURSOR_RULES:START -->\nUse FamiliarOS sparingly.\n<!-- FAMILIAROS:CURSOR_RULES:END -->\n",
rulesPath: ".cursor/rules/openpets.mdc",
rulesContent: "<!-- OPENPETS:CURSOR_RULES:START -->\nUse OpenPets sparingly.\n<!-- OPENPETS:CURSOR_RULES:END -->\n",
};
assert.equal(rulesPreview.rulesPath, ".cursor/rules/familiaros.mdc");
assert.match(rulesPreview.rulesContent, /FAMILIAROS:CURSOR_RULES:START/);
assert.equal(rulesPreview.rulesPath, ".cursor/rules/openpets.mdc");
assert.match(rulesPreview.rulesContent, /OPENPETS:CURSOR_RULES:START/);
assert.doesNotMatch(rulesPreview.rulesContent, /alwaysApply:\s*true/);
console.error("Cursor desktop validation passed.");

View file

@ -3,14 +3,14 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mapAsarPathToUnpacked } from "@familiaros/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
import { mapAsarPathToUnpacked } from "@open-pets/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
const root = mkdtempSync(join(tmpdir(), "familiaros-desktop-opencode-"));
const root = mkdtempSync(join(tmpdir(), "openpets-desktop-opencode-"));
try {
const globalDir = join(root, "opencode-global");
const bundledCli = mapAsarPathToUnpacked(join(root, "FamiliarOS.app", "Contents", "Resources", "app.asar", "node_modules", "@familiaros", "cli", "dist", "index.js"));
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");
@ -24,20 +24,20 @@ try {
});
assert.equal(install.configPath, join(globalDir, "opencode.jsonc"));
assert.equal(install.instructionPath, join(globalDir, "familiaros.md"));
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, /@familiaros\/opencode@4\.5\.6/);
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 familiaros?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] };
assert.deepEqual(previewConfig.mcp?.familiaros?.command, ["node", bundledCli, "mcp", "--familiar", "fixer"]);
assert.deepEqual(previewConfig.plugin, [["@familiaros/opencode@4.5.6", { familiar: "fixer" }]]);
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, "familiaros.md"), "utf8"), /FAMILIAROS:START/);
assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/);
const remove = prepareOpenCodeGlobalRemove(globalDir);
assert.equal(remove.configWrites.length, 1);

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,13 @@
/**
* Drift guard between the runtime plugin SDK and the published
* `@familiaros/plugin-sdk` type contract.
* `@open-pets/plugin-sdk` type contract.
*
* These are compile-time assertions: if the runtime's plugin-facing surface
* (namespaces) or the JavaScript permission set ever diverges from what the
* published package promises authors, `tsc` fails here. Keep the package and
* the bridge in lockstep.
*/
import type { FamiliarOSContext, FamiliarOSPermission } from "@familiaros/plugin-sdk";
import type { OpenPetsContext, OpenPetsPermission } from "@open-pets/plugin-sdk";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -20,12 +20,12 @@ import { pluginSdkAsyncRoutes, pluginSdkSyncRoutes, type PluginSdkRoute } from "
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// Every namespace the published SDK exposes (ctx.familiar, ctx.schedule, …) must
// Every namespace the published SDK exposes (ctx.pet, ctx.schedule, …) must
// exist on the runtime API, and the runtime must expose nothing extra.
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof FamiliarOSContext>>;
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof OpenPetsContext>>;
// The JavaScript plugin permission union must match the published contract.
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, FamiliarOSPermission>>;
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, OpenPetsPermission>>;
type _HostRoutesMatch = Expect<Equal<keyof typeof sdkCallHandlers, PluginSdkRoute>>;
// Reference the aliases so unused-type tooling never strips the guard.

View file

@ -2,37 +2,37 @@ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readF
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
export interface ClaudeFamiliarOSMemoryResult {
export interface ClaudeOpenPetsMemoryResult {
readonly changed: boolean;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export interface ClaudeFamiliarOSMemoryStatus {
export interface ClaudeOpenPetsMemoryStatus {
readonly status: "installed" | "not_installed" | "error";
readonly message: string;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export const openPetsClaudeImportLine = "@~/.claude/familiaros.md";
export const openPetsClaudeImportLine = "@~/.claude/openpets.md";
const openPetsImportStart = "<!-- FAMILIAROS:IMPORT:START -->";
const openPetsImportEnd = "<!-- FAMILIAROS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- FAMILIAROS:START -->";
const openPetsMemoryEnd = "<!-- FAMILIAROS:END -->";
const openPetsImportStart = "<!-- OPENPETS:IMPORT:START -->";
const openPetsImportEnd = "<!-- OPENPETS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- OPENPETS:START -->";
const openPetsMemoryEnd = "<!-- OPENPETS:END -->";
const maxClaudeMemoryBytes = 1024 * 1024;
export function installClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
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 currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
const nextFamiliarOSMemory = upsertFamiliarOSMemoryBlock(currentFamiliarOSMemory, createFamiliarOSMemoryBlock());
const openPetsChanged = currentFamiliarOSMemory !== nextFamiliarOSMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
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);
@ -42,7 +42,7 @@ export function installClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOS
return { changed: openPetsChanged || claudeMdChanged, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function uninstallClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
@ -55,18 +55,18 @@ export function uninstallClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliar
changed = true;
}
const currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
if (currentFamiliarOSMemory) {
const nextFamiliarOSMemory = removeFamiliarOSMemoryBlock(currentFamiliarOSMemory);
if (nextFamiliarOSMemory.trim().length === 0) {
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 (nextFamiliarOSMemory !== currentFamiliarOSMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
} else if (nextOpenPetsMemory !== currentOpenPetsMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
changed = true;
}
}
@ -74,26 +74,26 @@ export function uninstallClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliar
return { changed, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function doctorClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryStatus {
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 = createFamiliarOSBlockPattern().test(openPetsMemory) || /familiaros_say|FamiliarOS MCP/i.test(openPetsMemory);
const hasInstructions = createOpenPetsBlockPattern().test(openPetsMemory) || /openpets_say|OpenPets MCP/i.test(openPetsMemory);
if (hasImport && hasInstructions) {
return { status: "installed", message: "Claude will load FamiliarOS instructions from ~/.claude/familiaros.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
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 FamiliarOS instructions, but the FamiliarOS memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
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: "FamiliarOS instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
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 FamiliarOS instructions are not installed.", 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 FamiliarOS instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "error", message: error instanceof Error ? error.message : "Claude OpenPets instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
}
@ -102,12 +102,12 @@ export function getClaudeMemoryPaths(homeDir: string): { readonly claudeDir: str
return {
claudeDir,
claudeMdPath: join(claudeDir, "CLAUDE.md"),
openPetsMemoryPath: join(claudeDir, "familiaros.md"),
openPetsMemoryPath: join(claudeDir, "openpets.md"),
};
}
export function createFamiliarOSMemoryBlock(): string {
return `${openPetsMemoryStart}\n## FamiliarOS\n\nFamiliarOS MCP tools may be available.\n\nUse FamiliarOS as a short visible status channel for meaningful coding progress:\n- Use \`familiaros_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 \`familiaros_react\` for small visual or emotional feedback.\n- Use \`familiaros_status\` only when checking availability or the targeted familiar.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`;
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 {
@ -139,17 +139,17 @@ export function removeImportLine(source: string, importLine: string): string {
.replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : ""));
}
export function upsertFamiliarOSMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
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 removeFamiliarOSMemoryBlock(source: string): string {
const withoutBlock = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
export function removeOpenPetsMemoryBlock(source: string): string {
const withoutBlock = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
return withoutBlock ? `${withoutBlock}\n` : "";
}
function createFamiliarOSBlockPattern(): RegExp {
function createOpenPetsBlockPattern(): RegExp {
return new RegExp(`${escapeRegExp(openPetsMemoryStart)}[\\s\\S]*?${escapeRegExp(openPetsMemoryEnd)}\\n?`, "g");
}
@ -174,7 +174,7 @@ function assertSafeClaudeMemoryPaths(claudeDir: string, claudeMdPath: string, op
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 FamiliarOS to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
}
}
@ -202,7 +202,7 @@ 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 FamiliarOS to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
}
function escapeRegExp(value: string): string {

View file

@ -2,17 +2,17 @@
## Responsibility
Core TypeScript source for the FamiliarOS desktop application. Organized into: lifecycle management, state persistence, Control Center and familiar windows, IPC server, agent integrations, familiar installation/management, and declarative plus JavaScript plugin runtimes.
Core TypeScript source for the OpenPets desktop application. Organized into: lifecycle management, state persistence, Control Center and pet windows, IPC server, agent integrations, pet installation/management, and declarative plus JavaScript plugin runtimes.
## Design
- **Modular Controllers**: Separate controllers for default familiar vs agent familiars (lease-based)
- **Modular Controllers**: Separate controllers for default pet vs agent pets (lease-based)
- **Protocol-First IPC**: Versioned JSON protocol over TCP/Unix sockets with token auth
- **Defensive I/O**: All file operations use temp+rename for atomicity, path traversal validation, symlink checks
- **Validation at Boundaries**: Catalog, ZIP entries, familiar metadata, and IPC params all strictly validated
- **Lease Pattern**: Agent familiars use expiring leases (15s TTL) with heartbeats; default familiar is persistent
- **Sandboxed Renderers**: Control Center loads the Vite React/Tailwind bundle through a hardened BrowserWindow and narrow preload bridge; transparent familiar windows and plugin SDK host windows stay separate
- **Structured Logging**: Scoped logging (app, ipc, lease, familiar.*, state, tray, ui) with log rotation and redaction
- **Validation at Boundaries**: Catalog, ZIP entries, pet metadata, and IPC params all strictly validated
- **Lease Pattern**: Agent pets use expiring leases (15s TTL) with heartbeats; default pet is persistent
- **Sandboxed Renderers**: Control Center loads the Vite React/Tailwind bundle through a hardened BrowserWindow and narrow preload bridge; transparent pet windows and plugin SDK host windows stay separate
- **Structured Logging**: Scoped logging (app, ipc, lease, pet.*, state, tray, ui) with log rotation and redaction
- **Reaction Animation Mapping**: User-configurable mapping from reaction types to sprite animation states
- **Plugin Runtimes**: Plugins use validated manifests, approved permissions, persisted config, safe path checks, declarative timer-triggered actions, or sandboxed JavaScript entry modules through the SDK bridge.
@ -27,86 +27,55 @@ main.ts
├── plugin-service.ts (plugin state/runtime init, JS host wiring)
├── tray.ts (tray creation)
├── local-ipc.ts (IPC server start)
│ ├── local-ipc-request-handlers.ts (protocol request dispatch, familiar actions, lease routes, and memory CRUD shaping)
│ └── local-ipc-socket.ts (socket accept loop, advertised endpoint resolution, and remote address filtering)
└── windows.ts (UI handlers)
```
**IPC Request Flow**:
```
local-ipc.ts → parseIpcRequest() → handleLocalIpcRequest()
├── hello/status/familiars.list/familiars.install
local-ipc.ts → parseIpcRequest() → handleRequest()
├── hello/status/pets.list/pets.install
└── lease.acquire/heartbeat/release
└── lease-manager.ts
├── resolveTarget() (default vs explicit familiar)
├── onFirstExplicitLease → agent-familiar-controller.showAgentPet()
└── onLastExplicitLease → agent-familiar-controller.closeAgentPetIfOpen()
├── resolveTarget() (default vs explicit pet)
├── onFirstExplicitLease → agent-pet-controller.showAgentPet()
└── onLastExplicitLease → agent-pet-controller.closeAgentPetIfOpen()
└── Logging via logger.ts (ipc, lease scopes)
```
**Familiar Display Flow**:
**Pet Display Flow**:
```
familiar-window.ts
pet-window.ts
├── createDefaultPetWindow() / createAgentPetWindow()
├── familiar-window-host.ts (Electron BrowserWindow shell, navigation allowlist, and serialized HTML load queue)
├── familiar-window-interactions.ts (public interaction facade plus motion-state publishing)
├── familiar-window-mouse-interop.ts (public mouse passthrough/drag facade plus recovery accessors)
├── familiar-window-mouse-interop-runtime.ts (IPC handler attachment and lifecycle seam delegation for familiar window mouse interop)
├── familiar-window-mouse-interop-runtime-lifecycle.ts (listener registration, cleanup, and load/reset recovery for familiar window mouse interop)
├── familiar-window-mouse-interop-support.ts (context creation, platform passthrough helpers, and hit-test probes)
├── familiar-window-plugin-menu.ts (pet context menu, default-pet plugin commands, plugin command forms)
├── familiar-window-renderer-bridge.ts (bubble layout, reaction, sprite, audio, and TTS renderer IPC helpers)
├── loadDefaultPetContent() / loadExplicitPetContent()
│ ├── HTML generation with CSS sprite animation
│ ├── familiar-window-render.ts (shared familiar-window CSS/sprite shell plus bubble seam re-exports)
│ │ └── familiar-window-render-bubbles.ts (bubble/plugin HUD markup, status badges, and HTML escaping helpers)
│ ├── reaction-animation-mapping.ts (resolveReactionSpriteState)
│ ├── reaction-messages.ts (pickReactionMessage for bubbles)
│ └── Speech bubbles with status badges
└── familiar-preload.cjs (renderer IPC for drag/click-through)
└── pet-preload.cjs (renderer IPC for drag/click-through)
```
**Agent Setup Flow**:
```
windows.ts (IPC handlers)
└── agent-setup.ts
├── agent-setup-claude-status.ts
│ ├── Claude MCP preview/status detection and packaged-resource fallback shaping
│ └── Claude/OpenCode command wrappers, output sanitization, and user-path formatting
├── agent-setup-actions.ts
│ ├── runAgentSetupResolvedAction() shell and immediate action routing
│ ├── agent-setup-actions-claude-mcp.ts (Claude MCP add/remove/configure/replace orchestration)
│ └── agent-setup-actions-tooling.ts (OpenCode/Cursor setup snapshots, global config actions, and FamiliarOS MCP server health)
├── agent-setup-command-context.ts
│ ├── persisted command path preferences
│ └── CLI/package version resolution for agent setup
├── agent-setup-command-runner.ts
│ ├── Claude/OpenCode command execution and retries
│ └── PATH shaping plus command output summarization
├── agent-setup-support.ts
│ ├── FamiliarOS MCP preview shaping
│ ├── Claude memory install/uninstall safety wrappers
│ └── bounded action journal writes
├── agent-setup-editor-tools.ts
│ └── stable barrel over the editor-specific setup helper seams
├── agent-setup-editor-tools-opencode.ts
│ └── OpenCode global config management (@familiaros/opencode)
└── agent-setup-editor-tools-cursor.ts
└── Cursor global MCP config management (@familiaros/cursor)
├── detectClaudeCodeStatus() (claude --version, claude mcp list)
├── runAgentSetupAction()
│ ├── configure/replace/remove (MCP commands)
│ ├── install-memory (claude-memory.ts)
│ └── install-hooks/uninstall-hooks/doctor-hooks (@open-pets/claude)
├── OpenCode global config management (@open-pets/opencode)
└── Cursor global MCP config management (@open-pets/cursor)
```
**Familiar Installation Flow**:
**Pet Installation Flow**:
```
familiar-installation.ts
pet-installation.ts
├── installPet()
│ ├── getCatalogPet() → catalog.ts
│ ├── familiar-installation-archive.ts
│ │ ├── downloadPetZip() → validate ZIP magic
│ │ └── extractPetZip() → yauzl with entry validation
│ ├── downloadPetZip() → validate ZIP magic
│ ├── extractPetZip() → yauzl with entry validation
│ └── installPetState() → app-state.ts
│ ├── app-state-familiar-records.ts normalizes installed familiar records and file validation
│ └── app-state-storage.ts handles state-file migration, atomic writes, and install locks
└── importCodexPet() → codex-familiars.ts
└── importCodexPet() → codex-pets.ts
```
**Control Center Flow**:
@ -114,256 +83,113 @@ familiar-installation.ts
tray.ts → openControlCenterWindow(route) → windows.ts
├── hardened BrowserWindow loads Vite renderer or packaged dist/renderer/index.html
├── control-center-preload.cjs exposes page-specific APIs
├── control-center-ipc.ts installs the Control Center IPC shell
│ ├── control-center-ipc-settings.ts (settings, updates, TTS, clipboard, external links)
│ ├── control-center-ipc-knowledge.ts (memories and knowledge-store mutations)
│ ├── control-center-ipc-plugin.ts (plugin runtime/config/catalog/platform actions)
│ ├── control-center-ipc-pets.ts (catalog, local install, Codex import, default familiar actions)
│ └── control-center-ipc-agent.ts (agent setup, MCP toolkit, vanilla-chat MCP tools)
├── Dashboard snapshot: default familiar, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Familiars/Integrations/Plugins/Settings
```
**Prompt Window Flow**:
```
familiar-window preload double-click or tray/chat entry → openPromptWindow() → prompt-window.ts
├── prompt-window.ts owns BrowserWindow lifecycle and composes the extracted prompt window seams
├── prompt-window-bounds.ts owns bounded placement, resize normalization, and render sizing defaults
├── prompt-window-ipc.ts owns prompt/settings/conversation/file IPC routes and file-upload normalization
├── prompt-window-render.ts builds the inline prompt window HTML document/data URL
│ └── prompt-window-render-script.ts owns the injected prompt window client script
├── prompt-window-preload.cjs exposes the narrow prompt window bridge
└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-store.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, knowledge-store.ts, and knowledge-store-core.ts handle prompt submission, long-term memory recall, knowledge-file storage/search, credential persistence, request shaping, provider routing, history, and attachment storage
├── Dashboard snapshot: default pet, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Pets/Integrations/Plugins/Settings
```
**Plugin Flow**:
```
main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, ElectronPluginJsHost).start()
├── plugin-state.ts reads/writes userData/familiaros-plugin-state.json
├── plugin-state.ts reads/writes userData/openpets-plugin-state.json
├── plugin-runtime.ts reloads enabled manifests
│ ├── declarative runtime schedules timer triggers
│ ├── plugin-js-host.ts starts hidden sandboxed BrowserWindow hosts for JavaScript plugins
│ │ └── plugin-js-host-sdk-dispatch.ts owns the JavaScript SDK IPC route table and callback/config dispatch plumbing
│ └── plugin-sdk-bridge.ts → plugin-sdk-api-builder.ts, plugin-sdk-api-builder-platform.ts, plugin-sdk-familiar-surface.ts, plugin-sdk-bubble-support.ts, plugin-sdk-bubble-validation.ts, plugin-sdk-command-validation.ts, plugin-sdk-runtime-helpers.ts, plugin-sdk-bridge-runtime-state.ts, plugin-sdk-storage-stores.ts, plus plugin-familiar-api.ts/default-familiar-controller for schedules, storage, commands, status, logs, and restricted network
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, load-local, and runtime reloads while delegating the heavier catalog/install/uninstall/config-sound workflows into focused helper seams
│ ├── plugin-service-app.ts (app-global singleton, default familiar command/menu bridge, and test override hooks)
│ ├── plugin-service-support.ts (service barrel for action/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams)
│ ├── plugin-service-actions.ts (catalog snapshot/install-update, config-sound pick, and uninstall action workflows)
│ ├── plugin-service-guard-helpers.ts (service-local manifest/sound error shaping plus approval subset helpers)
│ ├── plugin-service-bundled-support.ts (bundled source lookup and bundled install materialization helpers)
│ ├── plugin-service-catalog-support.ts (catalog compatibility, status, and installed-record metadata helpers)
│ ├── plugin-service-dialog-support.ts (dialog picker defaults and permission confirmation helpers)
│ ├── plugin-service-local-support.ts (local dev plugin load, root scan, and stale plugins-dev prune helpers)
│ └── plugin-sdk-bridge.ts → plugin-pet-api.ts/default-pet-controller plus schedules, storage, commands, status, logs, and restricted network
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads
└── lifecycle.ts → stopPluginService() on quit
Control Center plugins route:
tray.ts → openControlCenterWindow("plugins") → windows.ts → renderer React app
└── familiaros:plugins-* IPC handlers call PluginService methods
└── openpets:plugins-* IPC handlers call PluginService methods
Catalog install/update:
plugin-catalog.ts → plugin-catalog-validation.ts
└── plugin-package.ts downloads HTTPS ZIP, validates SHA-256, extracts root manifest only, and installs to userData/plugins/{id}
Local development load:
plugin-service-local-support.ts → plugin-local-loader.ts validates selected folder manifests, snapshots only familiaros.plugin.json to userData/plugins-dev/{id}, and prunes stale dev installs after successful root scans
plugin-local-loader.ts validates selected folder manifest and snapshots only openpets.plugin.json to userData/plugins-dev/{id}
```
## Integration Points
- **Within src/**:
- `main.ts` → all modules (orchestrator), including `ElectronPluginJsHost` for JavaScript plugins
- `local-ipc.ts``lease-manager.ts``agent-familiar-controller.ts`
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-familiars.ts`, `update-checker.ts` for Control Center route snapshots/actions
- `local-ipc.ts``lease-manager.ts``agent-pet-controller.ts`
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-pets.ts`, `update-checker.ts` for Control Center route snapshots/actions
- `windows.ts``plugin-service.ts` for Control Center plugin UI IPC, plugin commands, and Dashboard plugin health
- `familiar-window.ts` ↔ `default-familiar-controller.ts`, `agent-familiar-controller.ts`
- `familiar-installation.ts` ↔ `app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `pet-window.ts` ↔ `default-pet-controller.ts`, `agent-pet-controller.ts`
- `pet-installation.ts` ↔ `app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `plugin-service.ts``plugin-state.ts`, `plugin-runtime.ts`, `plugin-catalog.ts`, `plugin-package.ts`, `plugin-local-loader.ts`, `plugin-js-host.ts`, `plugin-sdk-bridge.ts`
- **To packages/**:
- `@familiaros/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
- `@familiaros/opencode`: `prepareOpenCodeGlobalSetup`, `doctorOpenCodeGlobalSetup`
- `@familiaros/cursor`: `planCursorMcpInstall`, `executeCursorMcpWrite`, `buildCursorRulesPreview`, etc.
- `@familiaros/cli`: Version lookup for bundled mode
- `@open-pets/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
- `@open-pets/opencode`: `prepareOpenCodeGlobalSetup`, `doctorOpenCodeGlobalSetup`
- `@open-pets/cursor`: `planCursorMcpInstall`, `executeCursorMcpWrite`, `buildCursorRulesPreview`, etc.
- `@open-pets/cli`: Version lookup for bundled mode
- **To System**:
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, plugin storage JSON, `~/.codex/familiars/`, `~/.claude/`, `~/.opencode/`
- Network: `fetch()` to familiaros.dev, GitHub API, plugin catalog at `https://familiaros.dev/plugins/catalog.v1.json`, plugin ZIPs restricted to `https://zip.familiaros.dev/plugins/`
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, plugin storage JSON, `~/.codex/pets/`, `~/.claude/`, `~/.opencode/`
- Network: `fetch()` to openpets.dev, GitHub API, plugin catalog at `https://openpets.dev/plugins/catalog.v1.json`, plugin ZIPs restricted to `https://zip.openpets.dev/plugins/`
- Processes: `spawn()` for `claude`, `opencode`, `node`
## Key Modules
**Core**:
- `main.ts`: Entry, single-instance lock, bootstrap sequence, JavaScript plugin host construction
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and familiar windows on quit
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and pet windows on quit
- `state.ts`: Simple shell pause state
- `app-state.ts`: Persistent JSON state initialization and mutation API with reaction animation overrides
- `app-state-normalization.ts`: Extracted state schema, default-state factory, clone helper, and position/install normalization
- `app-state-preferences.ts`: Extracted state preference defaults plus OpenAPI/TTS/command-path normalization helpers
- `app-state-analytics.ts`: Extracted analytics normalization and activity accumulation helpers for persistent state
- `app-state-familiar-records.ts`: Extracted installed familiar record normalization, source shaping, and familiar file validation helpers
- `app-state-core.ts`: Familiar scale options, onboarding normalization
- `app-state-storage.ts`: Extracted state-file migration, atomic JSON persistence, and startup install-lock helpers
- `logger.ts`: Structured logging with scopes (app, ipc, lease, familiar.default, familiar.agent, familiar.window, state, tray, ui), log rotation, redaction
- `app-state.ts`: Persistent JSON state with V1 schema, atomic writes, reaction animation overrides
- `app-state-core.ts`: Pet scale options, onboarding normalization
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet.default, pet.agent, pet.window, state, tray, ui), log rotation, redaction
**UI**:
- `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, route-targeted Control Center entries, logs folder
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/familiar/settings UI IPC endpoints, and scoped internal protocols
- `control-center-ipc.ts`: Control Center IPC installer that composes extracted route registration seams for settings, knowledge, plugins, pets, and agent setup
- `control-center-core-services.ts`: Control Center service barrel for state snapshots, validation helpers, update helpers, and locale wiring
- `control-center-state.ts`: Control Center state snapshot and dashboard/preview loaders
- `control-center-state-validation.ts`: Extracted Control Center preferences patch, plain-object, and external-URL validation helpers
- `control-center-ipc-shared.ts`: Shared Control Center IPC sender validation and window-kind helpers
- `control-center-ipc-settings.ts`: Settings/update/TTS/openapi credential/clipboard/external-link IPC routes for the Control Center
- `control-center-ipc-knowledge.ts`: FamiliarOS memory and knowledge-store IPC routes for the Control Center
- `control-center-ipc-plugin.ts`: Plugin snapshot/config/catalog/platform IPC routes for the Control Center
- `control-center-ipc-pets.ts`: Familiar/catalog/Codex import and default familiar IPC routes for the Control Center
- `control-center-ipc-agent.ts`: Agent setup, toolkit, and FamiliarOS MCP preview/test IPC routes for the Control Center
- `agent-setup-actions.ts`: Extracted action execution shell for hook handling plus Claude MCP add/remove orchestration
- `agent-setup-actions-claude-mcp.ts`: Extracted Claude MCP add/remove/configure/replace orchestration plus memory-aware journaling
- `agent-setup-actions-tooling.ts`: Extracted OpenCode/Cursor setup loading, global config actions, and FamiliarOS MCP server health checks
- `agent-setup-editor-tools-opencode.ts`: Extracted OpenCode global setup status, previews, and config write helpers
- `agent-setup-editor-tools-cursor.ts`: Extracted Cursor global setup status, previews, and MCP config write helpers
- `agent-setup-support.ts`: Extracted MCP preview, Claude memory safety wrappers, and bounded action journal helpers for agent setup
- `mcp-toolkit-installer.ts`: Public MCP toolkit installer facade for persistent host-agent server setup and managed install result shaping
- `mcp-toolkit-installer-support.ts`: Extracted MCP toolkit bundle/server-definition, argument, note, and command-candidate helpers
- `mcp-toolkit-installer-commands.ts`: Extracted MCP toolkit PATH/process execution, timeout, and command-failure shaping helpers
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/pet/settings UI IPC endpoints, and scoped internal protocols
- `assets.ts`: Tray icon loading with generated fallback
- `display.ts`: Screen geometry helpers, familiar window positioning
- `prompt-window.ts`: Floating prompt window BrowserWindow lifecycle and seam composition for the prompt UI shell
- `prompt-window-bounds.ts`: Extracted prompt-window placement, resize normalization, and bounded work-area clamping
- `prompt-window-ipc.ts`: Extracted prompt-window IPC route installation plus knowledge-file upload normalization
- `prompt-window-render.ts`: Extracted inline prompt window renderer document builder with theme, conversation, attachment, and resize UI logic
- `prompt-window-render-script.ts`: Extracted inline prompt window client script builder for the prompt window renderer document
- `prompt-window-render-script-sections.ts`: Stable prompt-window client-script section barrel with bootstrap plus re-exports for the extracted render and layout builders
- `prompt-window-render-script-render-section.ts`: Extracted prompt-window history, conversation, feedback, busy-state, and attachment render helpers
- `prompt-window-render-script-layout-section.ts`: Extracted prompt-window resize queueing, editor shell layout, and refresh/render state helpers
- `prompt-window-render-script-interactions.ts`: Extracted prompt window client-script interaction and resize handlers
- `openapi-chat.ts`: Public OpenAPI chat facade, conversation/runtime state holder, and prompt-window entrypoint
- `openapi-chat-prompt-flows.ts`: Public prompt-flow barrel plus MCP-tool chat orchestration over the extracted plain-flow seam
- `openapi-chat-prompt-plain-flow.ts`: Extracted plain prompt submission flow, instruction-context assembly, and provider fallback handling
- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, endpoint normalization, and required-credential helpers
- `openapi-chat-model.ts`: Extracted pure OpenAPI chat model-name normalization helper
- `openapi-chat-presentation.ts`: Extracted familiar/TTS pending, success, and failure presentation helpers for prompt-window chat
- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows
- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers
- `familiaros-memory-search.ts`: Extracted pure memory query normalization, scoring, recall-intent fallback, chat-history relevance, and context-block shaping for FamiliarOS memory
- `knowledge-store.ts`: Public knowledge-store facade that combines file search/storage with FamiliarOS memory search results
- `knowledge-store-core.ts`: Extracted disk-backed knowledge file store class for CRUD, index caching, and path lookup
- `knowledge-store-core-support.ts`: Extracted knowledge-store public support seam for file validation, search/context assembly, normalization, and atomic writes
- `knowledge-store-core-helpers.ts`: Low-level knowledge-store parsing, MIME/text inference, query scoring, and tokenization helpers used by the public support seam
- `openapi-chat-tool-loop.ts`: Extracted chat-completions tool-call loop, history seeding, tool-result threading, and tool-aware instruction helpers for prompt-window OpenAPI chat
- `openapi-chat-config-store.ts`: Extracted credential persistence, storage-mode selection, current/legacy config path handling, and stored-credential decode helpers for prompt-window OpenAPI chat
- `openapi-chat-request-helpers.ts`: Extracted pure request-body and chat-completions history shaping for prompt-window OpenAPI chat
- `openapi-chat-conversation-store.ts`: Extracted current-conversation state ownership and disk-backed persistence for prompt-window chat
- `openapi-chat-conversation-store-core.ts`: Pure transcript normalization, per-conversation capping, and title helpers for the extracted conversation store seam
- `openapi-chat-provider.ts`: Stable provider barrel for prompt-window OpenAPI chat transport helpers.
- `openapi-chat-provider-support.ts`: Public provider support barrel that keeps prompt-window imports stable while forwarding to focused routing and payload seams.
- `openapi-chat-provider-routing.ts`: Extracted endpoint routing, auth header shaping, provider labeling, and normalized provider-error fallback decisions.
- `openapi-chat-provider-payloads.ts`: Extracted responses/chat-completions payload parsing and tool-call envelope extraction for the MCP loop.
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Familiars, Integrations, Plugins, and Settings, with extracted integrations/settings/helper route seams under `renderer/src/control-center/`, including the `settings-view-state.ts` hook seam, the `settings-view-state-actions.ts` route action shell, and the extracted state plugin/knowledge/type seams that keep route mutations bounded.
- `display.ts`: Screen geometry helpers, pet window positioning
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Pets, Integrations, Plugins, and Settings.
**Familiars**:
- `familiar-window.ts`: Default and agent familiar entrypoints, content reload orchestration, sprite animation states, speech bubble lifecycle, status badges, transient displays
- `familiar-window-host.ts`: Extracted Electron familiar window shell, renderer navigation hardening, and serialized HTML reload queue
- `familiar-window-content.ts`: Extracted familiar HTML render builders and installed-familiar fallback logic
- `familiar-window-layout.ts`: Extracted familiar bubble sizing, scroll-cap selection, and base window layout calculations
- `familiar-window-interactions.ts`: Public familiar-window interaction facade that re-exports mouse interop and owns motion-state publishing
- `familiar-window-mouse-interop.ts`: Public familiar mouse passthrough, drag/scale state, and recovery facade over the extracted runtime seam
- `familiar-window-mouse-interop-runtime.ts`: Extracted familiar mouse IPC handlers and delegation into the lifecycle attachment seam
- `familiar-window-mouse-interop-runtime-lifecycle.ts`: Extracted familiar mouse window/webContents listener attachment, cleanup, navigation/load reset, and recovery registration
- `familiar-window-mouse-interop-support.ts`: Extracted familiar mouse interop context, platform-specific passthrough rearm helpers, cursor hit-test probes, and payload guards
- `familiar-window-plugin-menu.ts`: Extracted pet context-menu construction, default familiar plugin command menus, and plugin command form hosting
- `familiar-window-renderer-bridge.ts`: Extracted renderer IPC helpers for bubble layout updates, reaction state, sprite overrides, audio playback, and TTS
- `familiar-window-render.ts`: Shared familiar-window CSS/sprite render shell plus stage markup and bubble seam re-exports
- `familiar-window-render-bubbles.ts`: Extracted familiar-window bubble/plugin HUD markup, status badge, and HTML escaping helpers
- `default-familiar-controller.ts`: Default familiar visibility, position persistence, logging, and composition of the extracted display/external-action/motion/bubble/window seams
- `default-familiar-controller-bubbles.ts`: Extracted default familiar plugin-bubble slot state, arbiter routing, and familiar-surface refresh/show callbacks
- `default-familiar-controller-display.ts`: Extracted transient display timers, status badge lifetimes, and dismiss-token shaping
- `default-familiar-controller-display-support.ts`: Extracted controller-local display helper factory for dismiss handling, badge wiring, and scale persistence
- `default-familiar-controller-external.ts`: Extracted external reaction/say/internal-message/status/move orchestration for the default familiar control surface
- `default-familiar-controller-motion.ts`: Extracted default familiar movement blocking and animated reposition helpers
- `default-familiar-controller-window.ts`: Extracted default familiar content reload, hide/destroy lifecycle, display handler wiring, bounds recovery, and resume/topology mouse-interop helpers
- `agent-familiar-controller.ts`: Lease-triggered familiar windows, dismissal tracking, transient displays, status badges, logging
- `plugins-view-presentation.tsx`: Extracted Control Center plugins route iconography, label maps, permission tones, and primary/status badge helpers
- `plugins-view-state.ts`: Extracted Control Center plugins route state, selection/filter derivation, and install/update/load actions
- `built-in-familiar.ts`: Built-in familiar constant
**Pets**:
- `pet-window.ts`: Window creation (transparent, frameless, always-on-top), HTML/CSS generation, sprite animation states, speech bubbles, status badges, transient displays
- `default-pet-controller.ts`: Default pet visibility, position persistence, transient reactions, status badges, logging
- `agent-pet-controller.ts`: Lease-triggered pet windows, dismissal tracking, transient displays, status badges, logging
- `built-in-pet.ts`: Built-in pet constant
- `reaction-messages.ts`: Message pools for each reaction type
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping, user-configurable overrides, sprite state definitions
**IPC**:
- `local-ipc.ts`: net.Server lifecycle, discovery file management, lease wiring, and request-entry logging
- `local-ipc-request-handlers.ts`: Extracted local IPC protocol request dispatch, status shaping, familiar actions, and memory CRUD handlers
- `local-ipc-socket.ts`: Extracted socket accept loop, advertised endpoint resolution, and loopback/private address filtering
- `local-ipc.ts`: net.Server implementation, request routing, discovery file management, network security (loopback/private address filtering), logging
- `local-ipc-protocol.ts`: Protocol constants, request/response types, validation functions
- `local-ipc-paths.ts`: Platform-specific socket paths and discovery file locations
- `lease-manager.ts`: Lease lifecycle (acquire, heartbeat, release, cleanup), target resolution
**Installation**:
- `familiar-installation.ts`: ZIP download, yauzl extraction with safety limits, familiar validation
- `familiar-installation-archive.ts`: Extracted ZIP download, yauzl entry validation, and bounded archive extraction
- `familiar-paths.ts`: Safe path resolution for familiar directories
- `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation
- `codex-familiars-core.ts`: Codex metadata validation constants
- `catalog.ts`: Catalog UI-state assembly, v3 surfaceability paging, pet lookup, and fixture fallback presentation.
- `catalog-remote.ts`: Extracted remote catalog v2/v3 fetch/cache plumbing, fixture loading, and bounded response validation.
- `pet-installation.ts`: ZIP download, yauzl extraction with safety limits, pet validation
- `pet-paths.ts`: Safe path resolution for pet directories
- `codex-pets.ts`: Import from `~/.codex/pets/` with validation
- `codex-pets-core.ts`: Codex metadata validation constants
- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback
- `catalog-validation.ts`: CatalogV2/V3 schema validation
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
**Plugins**:
- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, and exported manifest constants.
- `plugin-manifest-config-validation.ts`: Extracted config-schema plus timer trigger/action validation for plugin-manifest, including reaction-select config checks.
- `plugin-manifest-shape-validation.ts`: Extracted manifest shape validation for shared string/object guards, entry/icon/network checks, and v3 asset/panel declarations.
- `plugin-manifest.ts`: Manifest V1/V2 schema/types and validation for declarative and JavaScript runtimes, permissions (`timer`/`schedule`, `pet:speak`, `pet:reaction`, `storage`, `status`, `commands`, `network`), config schema, timer triggers, entry files, and pet actions.
- `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching.
- `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references.
- `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-state.ts`: Persistent plugin state store (`openpets-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timer triggers, starts/stops JavaScript plugin hosts, verifies approved permissions, exposes public command/status state, validates actions, schedules cancellable timers, and marks broken plugins on validation/action failure.
- `plugin-familiar-api.ts`: Narrow adapter from plugin actions to default familiar external `say`/`react` controller calls.
- `plugin-familiar-registry.ts`: Plugin-spawned familiar lifecycle ownership, list/change subscriptions, per-plugin teardown, and delegation into the extracted familiar action seam.
- `plugin-familiar-registry-actions.ts`: Extracted plugin familiar reaction/animation/status/motion/state/arbiter routing for default and spawned familiar handles.
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, local load, bundled seeding, and delegation into the extracted bundled lifecycle plus catalog/install/uninstall/config-sound seams.
- `plugin-service-app.ts`: Extracted app-global plugin-service singleton, default familiar command/menu bridge, and test override hooks
- `plugin-service-support.ts`: Service barrel for the extracted plugin-service action/bundled-lifecycle/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams
- `plugin-service-actions.ts`: Extracted catalog snapshot/install-update, config-sound picker, and uninstall workflows for plugin-service
- `plugin-service-guard-helpers.ts`: Extracted manifest/sound-safe error shaping plus permission/network approval subset helpers for plugin-service
- `plugin-service-bundled-lifecycle.ts`: Extracted bundled plugin seed/prune lifecycle orchestration, official bundled id ownership, and default-enable preservation for plugin-service
- `plugin-service-bundled-support.ts`: Extracted bundled source lookup and bundled install materialization helpers for plugin-service
- `plugin-service-catalog-support.ts`: Extracted catalog compatibility, version gating, and installed-record metadata helpers for plugin-service
- `plugin-service-dialog-support.ts`: Extracted dialog picker defaults and permission confirmation helpers for plugin-service
- `plugin-service-local-support.ts`: Extracted local dev-plugin load, root scan, and stale plugins-dev pruning helpers for plugin-service
- `plugin-service-default-pet.ts`: Extracted default familiar command/menu list shaping for plugin-service and familiar-window plugin menus
- `plugin-service-snapshot.ts`: Extracted safe plugin snapshot shaping, locale/icon/config resolution, and runtime command/status projection for plugin-service
- `plugin-service-text-helpers.ts`: Extracted UI-safe plugin text shaping, config/command display translation, and command-error sanitization for plugin-service
- `plugin-pet-api.ts`: Narrow adapter from plugin actions to default pet external `say`/`react` controller calls.
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, catalog install/update, local load, uninstall, permission prompts, compatibility checks, JavaScript host/SDK bridge integration, and runtime reloads.
- `plugin-catalog.ts`: Remote plugin catalog fetch with timeout, redirect rejection, response size cap, cache, and refresh support.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum FamiliarOS version.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum OpenPets version.
- `plugin-package.ts`: Catalog plugin package download/install with HTTPS host/path allowlist, SHA-256 verification, ZIP size/entry restrictions, manifest/catalog consistency checks, and safe uninstall path resolution.
- `plugin-local-loader.ts`: Developer loader that validates a selected local folder and snapshots only the manifest into `plugins-dev` with symlink/path/size protections.
- `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, startup/registration orchestration, and teardown.
- `plugin-js-host-sdk-dispatch.ts`: Extracted JavaScript plugin SDK IPC installation, route-dispatch table, callback bridging, config listener cleanup, and legacy error alias shaping for the hidden host.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK shell that owns runtime state, host defaults, and delegation into the extracted API builder plus runtime-state/helper seams.
- `plugin-sdk-api-builder.ts`: Public JavaScript plugin SDK namespace builder that composes extracted familiar, UI, storage, bus, and platform seams.
- `plugin-sdk-api-builder-platform.ts`: Extracted JavaScript plugin SDK platform namespaces for network, notifications, AI, secrets, voice, auth, system, commands, status, HTTP, logs, and locale helpers.
- `plugin-sdk-api-builder-platform-files.ts`: Extracted JavaScript plugin SDK files namespace builder for picker, handle-read, and save flows.
- `plugin-sdk-familiar-surface.ts`: Extracted familiar, familiar-manager, asset-resolution, and schedule namespace assembly for the JavaScript plugin SDK builder.
- `plugin-sdk-bubble-support.ts`: Extracted bubble text/markdown, action/input, host-icon, accent, and CSS color validation helpers for the JavaScript plugin SDK UI surface
- `plugin-sdk-bubble-validation.ts`: Extracted bubble descriptor, indicator, and HUD validation helpers for the JavaScript plugin SDK bridge UI surface
- `plugin-sdk-command-validation.ts`: Extracted command metadata, icon, form-schema, and form-value validation helpers for the JavaScript plugin SDK bridge
- `plugin-sdk-validators.ts`: Stable validator facade for shared plugin SDK validation, scheduling, movement, OAuth, AI request, and runtime-safe JSON helpers
- `plugin-sdk-bridge-runtime-state.ts`: Extracted bridge runtime-state shaping, command/menu callback execution, schedule resync, and cleanup helpers for the JavaScript plugin SDK bridge.
- `plugin-sdk-runtime-helpers.ts`: Extracted schedule timing, timeout wrapping, bubble counting, and callback error normalization helpers for the JavaScript plugin SDK bridge.
- `plugin-sdk-storage-stores.ts`: Extracted JSON and in-memory plugin storage-store implementations for the JavaScript plugin SDK bridge.
- `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, SDK IPC tokening, registration handshake, config listener cleanup, and teardown.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for pet speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
**Agent Integration**:
- `agent-setup.ts`: Agent-setup public entry points, snapshot assembly, selected-pet validation, and action journal coordination
- `agent-setup-actions.ts`: Extracted action execution shell plus immediate editor, hook, and memory action routing
- `agent-setup-actions-claude-mcp.ts`: Extracted Claude MCP add/remove/configure/replace orchestration plus memory-aware journaling
- `agent-setup-actions-tooling.ts`: Extracted editor setup loading, global config actions, and FamiliarOS MCP server health checks
- `agent-setup-claude-status.ts`: Extracted Claude MCP preview/status detection, command wrappers, output sanitization, and packaged-resource error shaping
- `agent-setup-command-context.ts`: Agent setup command path persistence, preferred command resolution, and workspace package metadata helpers
- `agent-setup-command-runner.ts`: Extracted agent-setup command execution, PATH shaping, Claude command candidate selection, and sanitized command result summarization
- `agent-setup-editor-tools.ts`: Stable barrel for the extracted OpenCode and Cursor editor setup helper seams
- `agent-setup-editor-tools-opencode.ts`: Extracted OpenCode global setup status, previews, and config write helpers
- `agent-setup-editor-tools-cursor.ts`: Extracted Cursor global setup status, previews, and MCP config write helpers
- `claude-memory.ts`: Claude instructions file management (`~/.claude/familiaros.md`)
- `agent-setup.ts`: Claude/OpenCode/Cursor detection, MCP configuration, hooks management, action journaling
- `claude-memory.ts`: Claude instructions file management (`~/.claude/openpets.md`)
- `update-checker.ts`: GitHub release polling, update status
- `update-version.ts`: Version parsing and comparison
@ -377,16 +203,16 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
| Source | Destination | Data |
|--------|-------------|------|
| Catalog API | `catalog.ts` | `CatalogV2/V3` JSON with pagination |
| ZIP Download | `familiar-installation.ts` | Extracted to `userData/familiars/{id}/` |
| `app-state.ts` | `userData/familiaros-state.json` | Atomic JSON writes with reaction animation overrides |
| CLI via IPC | `local-ipc.ts` | `familiar.react`, `familiar.say`, `lease.*` |
| `lease-manager.ts` | `agent-familiar-controller.ts` | Show/close agent familiars |
| ZIP Download | `pet-installation.ts` | Extracted to `userData/pets/{id}/` |
| `app-state.ts` | `userData/openpets-state.json` | Atomic JSON writes with reaction animation overrides |
| CLI via IPC | `local-ipc.ts` | `pet.react`, `pet.say`, `lease.*` |
| `lease-manager.ts` | `agent-pet-controller.ts` | Show/close agent pets |
| `windows.ts` | Renderer | State snapshots via IPC invoke |
| `agent-setup.ts` | Claude/OpenCode/Cursor CLI | MCP add/remove, config writes |
| All modules | `logger.ts` | Structured logs to `userData/logs/familiaros.log` |
| All modules | `logger.ts` | Structured logs to `userData/logs/openpets.log` |
| Plugin catalog | `plugin-catalog.ts`/`plugin-service.ts` | Discoverable plugin metadata filtered by app version and install state |
| Plugin ZIP/local folder | `plugin-package.ts`/`plugin-local-loader.ts` | Validated manifest snapshot installed under `userData/plugins*` |
| `plugin-state.ts` | `userData/familiaros-plugin-state.json` | Installed plugins, enabled flag, approved permissions, config, broken status |
| Control Center renderer | `control-center-preload.cjs`/`windows.ts` | Narrow Dashboard/Familiars/Integrations/Plugins/Settings snapshots and route-targeted actions |
| `plugin-runtime.ts` | `plugin-familiar-api.ts`/`plugin-js-host.ts`/`plugin-sdk-bridge.ts` | Declarative timers and JavaScript SDK actions on default familiar, schedules, storage, commands, status, logs, and network |
| `plugin-state.ts` | `userData/openpets-plugin-state.json` | Installed plugins, enabled flag, approved permissions, config, broken status |
| Control Center renderer | `control-center-preload.cjs`/`windows.ts` | Narrow Dashboard/Pets/Integrations/Plugins/Settings snapshots and route-targeted actions |
| `plugin-runtime.ts` | `plugin-pet-api.ts`/`plugin-js-host.ts`/`plugin-sdk-bridge.ts` | Declarative timers and JavaScript SDK actions on default pet, schedules, storage, commands, status, logs, and network |
| Plugins renderer | `windows.ts`/`plugin-service.ts` | Snapshot, enable, config, command, reload, install/update/uninstall, local-load operations |

View file

@ -11,13 +11,13 @@ export interface CodexPetMetadata {
}
export function validateCodexPetMetadata(value: unknown, folderName: string): CodexPetMetadata {
if (!isSafeCodexPetId(folderName)) throw new Error("Codex familiar folder name is invalid.");
if (!isRecord(value)) throw new Error("familiar.json must be an object.");
if (value.id !== folderName || typeof value.id !== "string") throw new Error("Codex familiar id must match its folder name.");
if (!isSafeCodexPetId(value.id)) throw new Error("Codex familiar id is invalid.");
if (typeof value.displayName !== "string" || value.displayName.trim().length === 0 || value.displayName.length > 80) throw new Error("Codex familiar displayName is invalid.");
if (typeof value.description !== "string" || value.description.trim().length === 0 || value.description.length > 500) throw new Error("Codex familiar description is invalid.");
if (value.spritesheetPath !== "spritesheet.webp") throw new Error("Codex familiar spritesheetPath must be spritesheet.webp.");
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(),

View file

@ -3,18 +3,18 @@ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile }
import { homedir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { getAppStateSnapshot, installPetState, type FamiliarOSStateV1 } from "./app-state.js";
import { maxCodexPetJsonBytes, maxCodexPets, maxCodexSpritesheetBytes, maxCodexThumbnailSourceBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-familiars-core.js";
import { withPetOperation } from "./familiar-installation.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./familiar-paths.js";
import { getAppStateSnapshot, installPetState, type OpenPetsStateV1 } from "./app-state.js";
import { maxCodexPetJsonBytes, maxCodexPets, maxCodexSpritesheetBytes, maxCodexThumbnailSourceBytes, 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", "familiars");
const codexPetsRoot = join(homedir(), ".codex", "pets");
const codexThumbnailCache = new Map<string, string>();
let sharpFactoryPromise: Promise<any | null> | null = null;
export interface CodexPetUiState {
readonly source: "codex";
readonly familiars: readonly CodexPetUiItem[];
readonly pets: readonly CodexPetUiItem[];
readonly error?: string;
}
@ -30,32 +30,32 @@ 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 familiars: CodexPetUiItem[] = [];
const pets: CodexPetUiItem[] = [];
let attemptedDirectories = 0;
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
attemptedDirectories += 1;
if (attemptedDirectories > maxCodexPets) break;
const familiar = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (familiar) {
familiars.push(familiar);
const pet = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (pet) {
pets.push(pet);
}
}
familiars.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", familiars };
pets.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", pets };
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", familiars: [] };
return { source: "codex", familiars: [], error: error instanceof Error ? error.message : "Codex familiars unavailable." };
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<FamiliarOSStateV1> {
export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
throw new Error(`Familiar is already installed: ${petId}`);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
}
const root = await validateCodexRoot();
@ -73,13 +73,13 @@ export async function importCodexPet(petId: string): Promise<FamiliarOSStateV1>
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "familiar.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", 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, "familiar.json"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
return installPetState({
id: metadata.id,
displayName: metadata.displayName,
@ -108,10 +108,10 @@ async function tryReadCodexPet(root: string, dir: string, folderName: string): P
displayName: metadata.displayName,
description: metadata.description,
preview,
spritesheet: `familiaros-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
spritesheet: `openpets-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
};
} catch (error) {
console.error(`Skipping invalid Codex familiar at ${dir}.`, error);
console.error(`Skipping invalid Codex pet at ${dir}.`, error);
return null;
}
}
@ -127,8 +127,8 @@ export async function readCodexPetSpritesheet(petId: string): Promise<Buffer> {
async function readCodexPetMetadata(root: string, dir: string, folderName: string): Promise<CodexPetMetadata> {
await assertCodexPetDirectory(root, dir);
assertSafePetId(folderName);
const petJson = join(dir, "familiar.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
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;
@ -181,23 +181,23 @@ async function loadSharpFactory(): Promise<any | null> {
async function validateCodexRoot(): Promise<string> {
const root = resolve(codexPetsRoot);
const rootStats = await lstat(root);
if (rootStats.isSymbolicLink()) throw new Error("Codex familiars root cannot be a symlink.");
if (!rootStats.isDirectory()) throw new Error("Codex familiars path is not a directory.");
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 familiars root path is not canonical.");
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 familiars directory.");
throw new Error("Resolved path escapes Codex pets directory.");
}
const dirStats = await lstat(resolvedTarget);
if (dirStats.isSymbolicLink()) throw new Error("Codex familiar directory cannot be a symlink.");
if (!dirStats.isDirectory()) throw new Error("Codex familiar path must be a directory.");
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 familiar directory escapes Codex familiars root.");
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> {
@ -218,8 +218,8 @@ async function readRegularFile(path: string, maxBytes: number, label: string): P
async function validateInstalledRegularFile(path: string): Promise<void> {
const stats = await lstat(path);
if (stats.isSymbolicLink()) throw new Error("Imported familiar file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported familiar file must be a regular file.");
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 {

View file

@ -1,10 +0,0 @@
export {
buildFamiliarOSMcpServerPreview,
getAgentSetupSnapshot,
runAgentSetupAction,
testFamiliarOSMcpServer,
updateAgentSetupCommandPaths,
} from "./agent-setup.js";
export { refreshAgentPetContent } from "./agent-familiar-controller.js";
export { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js";
export { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js";

View file

@ -1,18 +0,0 @@
export { getAppStateSnapshot, updatePreferences } from "./app-state.js";
export {
getDashboardSnapshot,
getI18nSnapshot,
getLaunchAtLoginState,
getPetsStateSnapshot,
getReactionAnimationSettingsSnapshot,
getSettingsStateSnapshot,
} from "./control-center-state.js";
export {
isPlainObject,
validateExternalUrl,
validatePreferencePatch,
} from "./control-center-state-validation.js";
export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js";
export { debug, error as logError, warn } from "./logger.js";
export { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";

View file

@ -1,23 +0,0 @@
export {
forgetFamiliarOSMemory,
listFamiliarOSMemories,
searchFamiliarOSMemories,
storeFamiliarOSMemory,
updateFamiliarOSMemory,
} from "./familiaros-memory.js";
export {
addKnowledgeMemory,
deleteKnowledgeFile,
listKnowledgeFiles,
searchKnowledgeStore,
storeKnowledgeFile,
} from "./knowledge-store.js";
export {
clearOpenApiCredential,
getOpenApiChatSettingsSnapshot,
resetOpenApiConversationContext,
saveOpenApiCredential,
} from "./openapi-chat.js";
export { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js";
export { clearTtsCredential, saveTtsCredential } from "./tts-credentials.js";
export { fetchTtsVoiceList, getTtsSettingsSnapshot, speakTts, stopTts, type TtsProviderId } from "./tts-service.js";

View file

@ -1,116 +0,0 @@
import { ipcMain } from "electron";
import {
buildFamiliarOSMcpServerPreview,
getAgentSetupSnapshot,
getMcpChatClientManager,
installPersistentToolkit,
listMcpChatVanillaServerIds,
runAgentSetupAction,
testFamiliarOSMcpServer,
updateAgentSetupCommandPaths,
} from "./control-center-agent-services.js";
import {
getAppStateSnapshot,
updatePreferences,
} from "./control-center-core-services.js";
import {
assertAllowedSender,
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
type SupportedAgentSetupAction = Parameters<typeof runAgentSetupAction>[0];
type SupportedCommandMode = Parameters<typeof testFamiliarOSMcpServer>[1];
export function installControlCenterAgentHandlers(
getControlCenterWindow: ControlCenterWindowGetter,
): void {
ipcMain.handle("familiaros:install-mcp-toolkit", async (event, target: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (target !== "claude-user" && target !== "codex-global") {
throw new Error("Invalid MCP toolkit target.");
}
return installPersistentToolkit(target);
});
ipcMain.handle("familiaros:get-vanilla-chat-mcp-tools", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const state = getAppStateSnapshot();
return {
enabled: state.preferences.vanillaChatMcpTools ?? [],
available: listMcpChatVanillaServerIds(),
active: getMcpChatClientManager().getActiveServerIds(),
};
});
ipcMain.handle("familiaros:set-vanilla-chat-mcp-tools", async (event, toolIds: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!Array.isArray(toolIds) || !toolIds.every((id) => typeof id === "string")) {
throw new Error("Invalid vanilla chat MCP tool list.");
}
const validIds = new Set(listMcpChatVanillaServerIds());
const sanitized = toolIds.filter((id: string) => validIds.has(id));
updatePreferences({ vanillaChatMcpTools: sanitized });
await getMcpChatClientManager().startEnabledServers(sanitized);
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
});
ipcMain.handle("familiaros:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getAgentSetupSnapshot(selectedPetId, commandMode);
});
ipcMain.handle("familiaros:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isSupportedAgentSetupAction(action)) {
throw new Error("Invalid agent setup action.");
}
return runAgentSetupAction(action, selectedPetId, commandMode);
});
ipcMain.handle("familiaros:agent-setup-command-paths", (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return updateAgentSetupCommandPaths(patch);
});
ipcMain.handle("familiaros:familiaros-mcp-server-preview", (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return buildFamiliarOSMcpServerPreview(
normalizeSelectedPetId(selectedPetId),
normalizeCommandMode(commandMode),
);
});
ipcMain.handle("familiaros:test-familiaros-mcp-server", async (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return testFamiliarOSMcpServer(
normalizeSelectedPetId(selectedPetId),
normalizeCommandMode(commandMode),
);
});
}
function isSupportedAgentSetupAction(value: unknown): value is SupportedAgentSetupAction {
return value === "configure"
|| value === "replace"
|| value === "remove"
|| value === "install-memory"
|| value === "doctor-hooks"
|| value === "install-hooks"
|| value === "uninstall-hooks"
|| value === "opencode-install"
|| value === "opencode-remove"
|| value === "cursor-install"
|| value === "cursor-replace"
|| value === "cursor-remove";
}
function normalizeSelectedPetId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeCommandMode(value: unknown): SupportedCommandMode {
return value === "published" || value === "bundled" || value === "local"
? value
: "published";
}

View file

@ -1,131 +0,0 @@
import { readFile, stat } from "node:fs/promises";
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron";
import {
addKnowledgeMemory,
deleteKnowledgeFile,
forgetFamiliarOSMemory,
listFamiliarOSMemories,
listKnowledgeFiles,
searchFamiliarOSMemories,
searchKnowledgeStore,
storeFamiliarOSMemory,
storeKnowledgeFile,
updateFamiliarOSMemory,
} from "./control-center-data-services.js";
import {
assertAllowedSender,
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
export function installControlCenterKnowledgeHandlers(
getControlCenterWindow: ControlCenterWindowGetter,
): void {
ipcMain.handle("familiaros:get-memories", async (event, query: unknown, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const normalizedQuery = typeof query === "string" ? query.trim() : "";
const normalizedLimit = normalizeLimit(limit, 50, 100);
if (normalizedQuery) {
return searchFamiliarOSMemories(normalizedQuery, normalizedLimit).map((hit) => hit.entry);
}
return listFamiliarOSMemories(normalizedLimit);
});
ipcMain.handle("familiaros:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
return storeFamiliarOSMemory({
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
});
ipcMain.handle("familiaros:update-memory", async (event, id: unknown, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
const result = updateFamiliarOSMemory(id.trim(), {
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
if (!result) throw new Error("Memory not found.");
return result;
});
ipcMain.handle("familiaros:delete-memory", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
const removed = forgetFamiliarOSMemory(id.trim());
if (!removed) throw new Error("Memory not found.");
return true;
});
ipcMain.handle("familiaros:knowledge-list", async (event, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return listKnowledgeFiles(normalizeLimit(limit, 50, 100));
});
ipcMain.handle("familiaros:knowledge-search", async (event, query: unknown, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const normalizedQuery = typeof query === "string" ? query.trim() : "";
const normalizedLimit = normalizeLimit(limit, 10, 100);
if (!normalizedQuery) {
return {
files: listKnowledgeFiles(normalizedLimit).map((file) => ({ file, score: 0 })),
memories: [],
};
}
return searchKnowledgeStore(normalizedQuery, normalizedLimit);
});
ipcMain.handle("familiaros:knowledge-delete-file", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("File id is required.");
const removed = await deleteKnowledgeFile(id.trim());
if (!removed) throw new Error("File not found.");
return true;
});
ipcMain.handle("familiaros:knowledge-add-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
return addKnowledgeMemory({
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
});
ipcMain.handle("familiaros:knowledge-store-file", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const options: OpenDialogOptions = {
title: "Add file to Knowledge Store",
buttonLabel: "Add",
properties: ["openFile"],
filters: [{ name: "All files", extensions: ["*"] }],
};
const result = owner
? await dialog.showOpenDialog(owner, options)
: await dialog.showOpenDialog(options);
if (result.canceled || !result.filePaths[0]) return null;
const filePath = result.filePaths[0];
const stats = await stat(filePath);
if (!stats.isFile()) throw new Error("Selected path is not a file.");
const data = await readFile(filePath);
const name = filePath.replace(/\\/g, "/").split("/").pop() ?? "unnamed";
return storeKnowledgeFile({ name, data });
});
}
function normalizeLimit(value: unknown, fallback: number, max: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(1, Math.min(max, Math.round(value)))
: fallback;
}

View file

@ -1,190 +0,0 @@
import { stat } from "node:fs/promises";
import {
BrowserWindow,
dialog,
ipcMain,
shell,
type OpenDialogOptions,
} from "electron";
import {
debug,
getCatalogPageUiState,
getCatalogSearchUiState,
getCatalogUiState,
getPetsStateSnapshot,
getSettingsStateSnapshot,
getAppStateSnapshot,
logError,
} from "./control-center-core-services.js";
import {
installPet,
installPetFromFolder,
installPetFromZipFile,
recoverDefaultPetMouseInterop,
refreshDefaultPetContent,
removePet,
resetDefaultPetToInitialPosition,
setDefaultInstalledPet,
} from "./control-center-pet-services.js";
import {
assertAllowedSender,
isControlCenterSender,
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
export function installControlCenterPetHandlers(
getControlCenterWindow: ControlCenterWindowGetter,
): void {
ipcMain.handle("familiaros:get-familiars-state", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPetsStateSnapshot();
});
ipcMain.handle("familiaros:get-catalog", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getCatalogUiState();
});
ipcMain.handle("familiaros:get-catalog-page", async (event, page: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof page !== "number" || !Number.isInteger(page) || page < 0) {
throw new Error("Invalid catalog page.");
}
return getCatalogPageUiState(page);
});
ipcMain.handle("familiaros:get-catalog-search", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getCatalogSearchUiState();
});
ipcMain.handle("familiaros:get-codex-familiars", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return (await loadCodexPetsModule()).getCodexPetsUiState();
});
ipcMain.handle("familiaros:set-default-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
const state = await setDefaultInstalledPet(petId);
refreshDefaultPetContent();
recoverDefaultPetMouseInterop("default-familiar-changed");
setTimeout(() => recoverDefaultPetMouseInterop("default-familiar-changed+500ms"), 500).unref?.();
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getPetsStateSnapshot()
: state;
});
ipcMain.handle("familiaros:install-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
const state = await installPet(petId);
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getPetsStateSnapshot()
: state;
});
ipcMain.handle("familiaros:install-local-familiar", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const importKind = await chooseLocalPetImportKind(owner);
if (!importKind) return getPetsStateSnapshot();
const options: OpenDialogOptions = importKind === "zip"
? {
title: "Install familiar from ZIP",
buttonLabel: "Install Familiar",
properties: ["openFile"],
filters: [{ name: "FamiliarOS ZIP", extensions: ["zip"] }],
}
: {
title: "Install familiar from folder",
buttonLabel: "Install Familiar",
properties: ["openDirectory"],
};
const result = owner
? await dialog.showOpenDialog(owner, options)
: await dialog.showOpenDialog(options);
if (result.canceled || !result.filePaths[0]) return getPetsStateSnapshot();
const selectedPath = result.filePaths[0];
try {
const selectedStats = await stat(selectedPath);
const state = selectedStats.isDirectory()
? await installPetFromFolder(selectedPath)
: await installPetFromZipFile(selectedPath);
debug("ui", "local familiar import succeeded", {
kind: selectedStats.isDirectory() ? "folder" : "zip",
});
refreshDefaultPetContent();
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getPetsStateSnapshot()
: state;
} catch (error) {
logError("ui", "local familiar import failed", {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
});
ipcMain.handle("familiaros:open-gallery", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
await shell.openExternal("https://familiaros.dev/gallery");
});
ipcMain.handle("familiaros:import-codex-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
const state = await (await loadCodexPetsModule()).importCodexPet(petId);
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getPetsStateSnapshot()
: state;
});
ipcMain.handle("familiaros:remove-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
const state = await removePet(petId);
refreshDefaultPetContent();
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getPetsStateSnapshot()
: state;
});
ipcMain.handle("familiaros:reset-default-familiar-position", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
resetDefaultPetToInitialPosition();
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getSettingsStateSnapshot()
: getAppStateSnapshot();
});
}
async function loadCodexPetsModule() {
return import("./codex-familiars.js");
}
function chooseLocalPetImportKind(
owner: BrowserWindow | undefined,
): Promise<"zip" | "folder" | null> {
const options = {
type: "question" as const,
title: "Install familiar",
message: "Install familiar from ZIP or folder?",
detail: "Choose the source type before selecting the familiar package.",
buttons: ["ZIP", "Folder", "Cancel"],
defaultId: 0,
cancelId: 2,
noLink: true,
};
return (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)).then((result) => {
if (result.response === 0) return "zip";
if (result.response === 1) return "folder";
return null;
});
}

View file

@ -1,177 +0,0 @@
import { ipcMain } from "electron";
import {
getPluginService,
type PluginConfigSoundPickResult,
type PluginServiceResult,
} from "./control-center-data-services.js";
import {
debug,
isPlainObject,
logError,
warn,
} from "./control-center-core-services.js";
import {
assertAllowedSender,
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
const pluginIdPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
const pluginCommandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
export function installControlCenterPluginHandlers(
getControlCenterWindow: ControlCenterWindowGetter,
): void {
ipcMain.handle("familiaros:plugins-snapshot", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().getSnapshot();
});
ipcMain.handle("familiaros:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || typeof enabled !== "boolean") {
return pluginUiError("Invalid plugin enable request.");
}
return getPluginService().setEnabled(id, enabled);
});
ipcMain.handle("familiaros:plugins-save-config", async (event, id: unknown, config: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || !isPlainObject(config)) {
return pluginUiError("Invalid plugin config request.");
}
return getPluginService().saveConfig(id, config);
});
ipcMain.handle("familiaros:plugins-pick-config-sound", async (event, id: unknown): Promise<PluginConfigSoundPickResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) {
warn("ui", "Plugin sound pick invalid request.", { ok: false, reason: "invalid-plugin-id" });
return pluginUiSoundError("Invalid plugin sound request.");
}
debug("ui", "Plugin sound pick requested.", { pluginId: id });
try {
const result = await getPluginService().pickConfigSound(id);
if (result.ok && "sound" in result && result.sound.id) {
debug("ui", "Plugin sound pick succeeded.", { pluginId: id, ok: true, soundId: result.sound.id });
} else if (result.ok) {
debug("ui", "Plugin sound pick canceled.", { pluginId: id, ok: true, canceled: true });
} else {
warn("ui", "Plugin sound pick failed.", { pluginId: id, ok: false, reason: result.error });
}
return result;
} catch (error) {
logError("ui", "Plugin sound pick errored.", {
pluginId: id,
ok: false,
reason: error instanceof Error ? error.message : "unknown",
});
throw error;
}
});
ipcMain.handle("familiaros:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin reload request.");
return getPluginService().reload(id);
});
ipcMain.handle("familiaros:plugins-execute-command", async (event, id: unknown, commandId: unknown, args: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || !isValidPluginCommandId(commandId) || (args !== undefined && !isPlainObject(args))) {
return pluginUiError("Invalid plugin command request.");
}
return getPluginService().executeCommand(
id,
commandId,
isPlainObject(args) ? args as Record<string, unknown> : undefined,
);
});
ipcMain.handle("familiaros:plugins-load-local", async (event): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().loadLocal();
});
ipcMain.handle("familiaros:plugins-catalog-snapshot", async (event, refresh: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().getCatalogSnapshot(refresh === true);
});
ipcMain.handle("familiaros:plugins-install-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin install request.");
return getPluginService().installCatalog(id);
});
ipcMain.handle("familiaros:plugins-update-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin update request.");
return getPluginService().updateCatalog(id);
});
ipcMain.handle("familiaros:plugins-uninstall", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin uninstall request.");
return getPluginService().uninstall(id);
});
ipcMain.handle("familiaros:plugins-inspector", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) throw new Error("Invalid plugin inspector request.");
return getPluginService().runtime.getInspectorState(id);
});
ipcMain.handle("familiaros:plugin-platform-settings-get", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginPlatformSettings } = await import("./plugin-platform-settings.js");
return getPluginPlatformSettings();
});
ipcMain.handle("familiaros:plugin-platform-settings-update", async (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isPlainObject(patch)) throw new Error("Invalid plugin platform settings patch.");
const { updatePluginPlatformSettings } = await import("./plugin-platform-settings.js");
return updatePluginPlatformSettings(patch as never);
});
ipcMain.handle("familiaros:plugin-platform-ai-key-set", async (event, key: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
const capabilities = getPluginHostCapabilitiesForUi();
if (!capabilities) throw new Error("Plugin host capabilities are unavailable.");
if (key === null || key === "") {
await capabilities.secretsStore.delete(hostSecretsOwner, hostAiApiKeySecret);
return { ok: true, hasKey: false };
}
if (typeof key !== "string" || key.length > 4096) throw new Error("Invalid AI API key.");
await capabilities.secretsStore.set(hostSecretsOwner, hostAiApiKeySecret, key);
return { ok: true, hasKey: true };
});
ipcMain.handle("familiaros:plugin-platform-ai-key-status", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
const capabilities = getPluginHostCapabilitiesForUi();
if (!capabilities) return { hasKey: false };
return { hasKey: await capabilities.secretsStore.has(hostSecretsOwner, hostAiApiKeySecret) };
});
}
function pluginUiError(error: string): PluginServiceResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
function pluginUiSoundError(error: string): PluginConfigSoundPickResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
function isValidPluginId(value: unknown): value is string {
return typeof value === "string" && pluginIdPattern.test(value);
}
function isValidPluginCommandId(value: unknown): value is string {
return typeof value === "string" && pluginCommandIdPattern.test(value);
}

View file

@ -1,200 +0,0 @@
import { app, clipboard, ipcMain, shell } from "electron";
import { refreshAgentPetContent } from "./control-center-agent-services.js";
import {
clearOpenApiCredential,
clearTtsCredential,
fetchTtsVoiceList,
getOpenApiChatSettingsSnapshot,
getTtsSettingsSnapshot,
resetOpenApiConversationContext,
saveOpenApiCredential,
saveTtsCredential,
speakTts,
stopTts,
type TtsProviderId,
} from "./control-center-data-services.js";
import {
checkForGitHubReleaseUpdate,
getActiveLocale,
getDashboardSnapshot,
getI18nSnapshot,
getLaunchAtLoginState,
getSettingsStateSnapshot,
getReactionAnimationSettingsSnapshot,
getUpdateStatus,
openUpdateReleasePage,
setLocaleFromPreference,
updatePreferences,
validateExternalUrl,
validatePreferencePatch,
} from "./control-center-core-services.js";
import { refreshDefaultPetContent } from "./control-center-pet-services.js";
import {
assertAllowedSender,
isControlCenterSender,
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
export function installControlCenterSettingsHandlers(
getControlCenterWindow: ControlCenterWindowGetter,
): void {
ipcMain.handle("familiaros:get-settings-state", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getSettingsStateSnapshot();
});
ipcMain.handle("familiaros:get-i18n", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getI18nSnapshot();
});
ipcMain.handle("familiaros:get-dashboard-snapshot", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getDashboardSnapshot();
});
ipcMain.handle("familiaros:get-openapi-chat-settings", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getOpenApiChatSettingsSnapshot();
});
ipcMain.handle("familiaros:save-openapi-credential", (event, apiKey: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof apiKey !== "string") throw new Error("Invalid chat credential.");
return saveOpenApiCredential(apiKey);
});
ipcMain.handle("familiaros:clear-openapi-credential", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return clearOpenApiCredential();
});
ipcMain.handle("familiaros:get-reaction-animation-settings", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getReactionAnimationSettingsSnapshot();
});
ipcMain.handle("familiaros:update-preferences", (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const currentState = getSettingsStateSnapshot();
const previousScale = currentState.preferences.petScale;
const previousOverrides = JSON.stringify(currentState.preferences.reactionAnimationOverrides ?? {});
const previousLocale = getActiveLocale();
const nextState = updatePreferences(validatePreferencePatch(patch));
if (
currentState.preferences.openApiChatModel !== nextState.preferences.openApiChatModel
|| currentState.preferences.openApiChatEndpoint !== nextState.preferences.openApiChatEndpoint
) {
resetOpenApiConversationContext();
}
const nextOverrides = JSON.stringify(nextState.preferences.reactionAnimationOverrides ?? {});
if (nextState.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
refreshDefaultPetContent();
refreshAgentPetContent();
}
const nextLocale = setLocaleFromPreference(nextState.preferences.locale);
if (
nextLocale !== previousLocale
|| nextState.preferences.familiarName !== currentState.preferences.familiarName
) {
void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu());
if (nextLocale !== previousLocale) {
broadcastPluginRecordsRefresh(getControlCenterWindow);
}
}
return isControlCenterSender(getControlCenterWindow, event.sender.id)
? getSettingsStateSnapshot()
: nextState;
});
ipcMain.handle("familiaros:get-launch-at-login", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getLaunchAtLoginState();
});
ipcMain.handle("familiaros:set-launch-at-login", (event, enabled: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
return getLaunchAtLoginState();
});
ipcMain.handle("familiaros:get-update-status", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getUpdateStatus();
});
ipcMain.handle("familiaros:check-for-updates", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const status = await checkForGitHubReleaseUpdate();
const { refreshTrayMenu } = await import("./tray.js");
refreshTrayMenu();
return status;
});
ipcMain.handle("familiaros:open-update-release-page", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
await openUpdateReleasePage();
});
ipcMain.handle("familiaros:copy-text", (event, text: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid copy payload.");
clipboard.writeText(text);
});
ipcMain.handle("familiaros:open-external-url", async (event, rawUrl: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const url = validateExternalUrl(rawUrl);
await shell.openExternal(url);
});
ipcMain.handle("familiaros:get-tts-settings", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getTtsSettingsSnapshot();
});
ipcMain.handle("familiaros:get-tts-voices", async (event, provider: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
return fetchTtsVoiceList(provider as TtsProviderId);
});
ipcMain.handle("familiaros:save-tts-credential", (event, provider: unknown, credential: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
if (typeof credential !== "string") throw new Error("Credential must be a string.");
return saveTtsCredential(provider, credential);
});
ipcMain.handle("familiaros:clear-tts-credential", (event, provider: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
return clearTtsCredential(provider);
});
ipcMain.handle("familiaros:test-tts", async (event, text: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Text must be a non-empty string.");
await speakTts(text.trim(), "familiar");
return { ok: true };
});
ipcMain.handle("familiaros:tts-stop", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
stopTts("familiar");
});
}
function broadcastPluginRecordsRefresh(getControlCenterWindow: ControlCenterWindowGetter): void {
const controlCenterWindow = getControlCenterWindow();
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) {
controlCenterWindow.webContents.send("familiaros:plugins-refresh");
}
}
function isLaunchAtLoginSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}

View file

@ -1,37 +0,0 @@
import { BrowserWindow, type IpcMainInvokeEvent } from "electron";
export type InternalUiWindowKind = "control-center";
export type ControlCenterWindowGetter = () => BrowserWindow | null;
export function assertAllowedSender(
getControlCenterWindow: ControlCenterWindowGetter,
event: IpcMainInvokeEvent,
allowedKinds: readonly InternalUiWindowKind[],
): void {
const actualKind = getInternalUiWindowKindForWebContents(getControlCenterWindow, event.sender.id);
if (!actualKind || !allowedKinds.includes(actualKind)) {
throw new Error("FamiliarOS internal UI request came from an unexpected window.");
}
}
export function isControlCenterSender(
getControlCenterWindow: ControlCenterWindowGetter,
webContentsId: number,
): boolean {
return getInternalUiWindowKindForWebContents(getControlCenterWindow, webContentsId) === "control-center";
}
function getInternalUiWindowKindForWebContents(
getControlCenterWindow: ControlCenterWindowGetter,
webContentsId: number,
): InternalUiWindowKind | null {
const controlCenterWindow = getControlCenterWindow();
if (
controlCenterWindow
&& !controlCenterWindow.isDestroyed()
&& controlCenterWindow.webContents.id === webContentsId
) {
return "control-center";
}
return null;
}

View file

@ -1,32 +0,0 @@
import {
installControlCenterAgentHandlers,
} from "./control-center-ipc-agent.js";
import {
installControlCenterKnowledgeHandlers,
} from "./control-center-ipc-knowledge.js";
import {
installControlCenterPetHandlers,
} from "./control-center-ipc-pets.js";
import {
installControlCenterPluginHandlers,
} from "./control-center-ipc-plugin.js";
import {
type ControlCenterWindowGetter,
} from "./control-center-ipc-shared.js";
import {
installControlCenterSettingsHandlers,
} from "./control-center-ipc-settings.js";
let internalUiHandlersInstalled = false;
export function installInternalUiHandlers(getControlCenterWindow: ControlCenterWindowGetter): void {
if (internalUiHandlersInstalled) return;
internalUiHandlersInstalled = true;
installControlCenterSettingsHandlers(getControlCenterWindow);
installControlCenterKnowledgeHandlers(getControlCenterWindow);
installControlCenterPluginHandlers(getControlCenterWindow);
installControlCenterPetHandlers(getControlCenterWindow);
installControlCenterAgentHandlers(getControlCenterWindow);
}

View file

@ -1,12 +0,0 @@
export {
recoverDefaultPetMouseInterop,
refreshDefaultPetContent,
resetDefaultPetToInitialPosition,
} from "./default-familiar-controller.js";
export {
installPet,
installPetFromFolder,
installPetFromZipFile,
removePet,
setDefaultInstalledPet,
} from "./familiar-installation.js";

View file

@ -1,199 +0,0 @@
import {
normalizeFamiliarName,
normalizePetScale,
} from "./app-state-core.js";
import {
normalizeOpenApiChatEndpoint,
type FamiliarOSPreferences,
} from "./app-state-preferences.js";
import {
isSupportedLocale,
} from "./i18n/index.js";
import { normalizeChatModel } from "./openapi-chat-model.js";
import { validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
import { listTtsProviders } from "./tts-engine.js";
import { type TtsProviderId } from "./tts-service.js";
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
export type ControlCenterPreferencesPatch = Partial<Mutable<FamiliarOSPreferences>>;
export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch {
if (!isRecord(value)) {
throw new Error("Invalid preferences patch.");
}
const patch: ControlCenterPreferencesPatch = {};
if ("openDefaultPetOnLaunch" in value) {
if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value.");
patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch;
}
if ("locale" in value) {
if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value.");
patch.locale = value.locale;
}
if ("petScale" in value) {
const scale = normalizePetScale(value.petScale);
if (scale !== value.petScale) throw new Error("Invalid familiar scale value.");
patch.petScale = scale;
}
if ("reactionAnimationOverrides" in value) {
patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides);
}
if ("openApiChatModel" in value) {
if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") {
patch.openApiChatModel = undefined;
} else {
const model = normalizeChatModel(value.openApiChatModel);
if (!model) throw new Error("Invalid model name.");
patch.openApiChatModel = model;
}
}
if ("openApiChatSystemPrompt" in value) {
if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") {
patch.openApiChatSystemPrompt = undefined;
} else if (typeof value.openApiChatSystemPrompt === "string") {
patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt;
} else {
throw new Error("Invalid familiar character prompt.");
}
}
if ("openApiChatEndpoint" in value) {
if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") {
patch.openApiChatEndpoint = undefined;
} else {
const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint);
if (!endpoint) {
throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL.");
}
patch.openApiChatEndpoint = endpoint;
}
}
if ("openApiChatTheme" in value) {
if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") {
throw new Error("Invalid theme mode.");
}
patch.openApiChatTheme = value.openApiChatTheme;
}
if ("openApiChatBaseInstructionsEnabled" in value) {
if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") {
throw new Error("Invalid base instructions value.");
}
patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled;
}
if ("ttsProvider" in value) {
const validIds = listTtsProviders().map((provider) => provider.id);
if (!validIds.includes(value.ttsProvider as TtsProviderId)) {
throw new Error("Invalid TTS provider.");
}
patch.ttsProvider = value.ttsProvider as TtsProviderId;
}
if ("ttsVoice" in value) {
if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") {
patch.ttsVoice = undefined;
} else if (typeof value.ttsVoice === "string") {
patch.ttsVoice = value.ttsVoice;
} else {
throw new Error("Invalid TTS voice.");
}
}
if ("ttsSpeed" in value) {
const speed = Number(value.ttsSpeed);
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
throw new Error("Invalid TTS speed. Must be between 0.5 and 2.");
}
patch.ttsSpeed = Math.round(speed * 10) / 10;
}
if ("ttsModel" in value) {
if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") {
patch.ttsModel = undefined;
} else if (typeof value.ttsModel === "string") {
const trimmed = value.ttsModel.trim();
if (trimmed.length > 120) throw new Error("Invalid TTS model name.");
patch.ttsModel = trimmed;
} else {
throw new Error("Invalid TTS model.");
}
}
if ("ttsEndpointPreset" in value) {
if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") {
throw new Error("Invalid TTS endpoint preset.");
}
patch.ttsEndpointPreset = value.ttsEndpointPreset;
}
if ("ttsEndpoint" in value) {
if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") {
patch.ttsEndpoint = undefined;
} else {
const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint);
if (!endpoint) {
throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL.");
}
patch.ttsEndpoint = endpoint;
}
}
if ("familiarName" in value) {
if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") {
patch.familiarName = undefined;
} else if (typeof value.familiarName === "string") {
const name = normalizeFamiliarName(value.familiarName);
if (name === undefined) {
throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters.");
}
patch.familiarName = name;
} else {
throw new Error("Invalid Familiar name.");
}
}
return patch;
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value) as unknown;
return prototype === Object.prototype || prototype === null;
}
export function validateExternalUrl(value: unknown): string {
if (typeof value !== "string") {
throw new Error("Invalid URL.");
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("Invalid URL.");
}
const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
if ((parsed.protocol !== "https:" && !isLocalHttp)
|| parsed.username
|| parsed.password
|| parsed.hash) {
throw new Error("Only https URLs, or localhost http URLs, are allowed.");
}
return parsed.toString();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,177 +0,0 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { getAppStateSnapshot, petScaleOptions } from "./app-state.js";
import { getCatalogUiState } from "./catalog.js";
import { getInstalledPetDir } from "./familiar-paths.js";
import { getActiveLocale, getActiveMessages, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js";
import { warn } from "./logger.js";
import { getOpenApiChatSettingsSnapshot } from "./openapi-chat.js";
import { getPluginService } from "./plugin-service.js";
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata } from "./reaction-animation-mapping.js";
import { getUpdateStatus } from "./update-checker.js";
type ControlCenterSettingsPreferences = Pick<
ReturnType<typeof getAppStateSnapshot>["preferences"],
| "openDefaultPetOnLaunch"
| "locale"
| "petScale"
| "reactionAnimationOverrides"
| "openApiChatModel"
| "openApiChatSystemPrompt"
| "openApiChatEndpoint"
| "openApiChatTheme"
| "vanillaChatMcpTools"
| "openApiChatBaseInstructionsEnabled"
| "ttsProvider"
| "ttsVoice"
| "ttsSpeed"
| "ttsModel"
| "ttsEndpointPreset"
| "ttsEndpoint"
| "familiarName"
>;
export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType<typeof getAppStateSnapshot>["familiars"] } {
const state = getAppStateSnapshot();
return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars };
}
export function getSettingsStateSnapshot(): {
preferences: ControlCenterSettingsPreferences;
petScaleOptions: typeof petScaleOptions;
openApiChat: ReturnType<typeof getOpenApiChatSettingsSnapshot>;
} {
const state = getAppStateSnapshot();
return {
preferences: {
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
locale: state.preferences.locale,
petScale: state.preferences.petScale,
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
openApiChatModel: state.preferences.openApiChatModel,
openApiChatSystemPrompt: state.preferences.openApiChatSystemPrompt,
openApiChatEndpoint: state.preferences.openApiChatEndpoint,
openApiChatTheme: state.preferences.openApiChatTheme,
vanillaChatMcpTools: state.preferences.vanillaChatMcpTools,
openApiChatBaseInstructionsEnabled: state.preferences.openApiChatBaseInstructionsEnabled,
ttsProvider: state.preferences.ttsProvider,
ttsVoice: state.preferences.ttsVoice,
ttsSpeed: state.preferences.ttsSpeed,
ttsModel: state.preferences.ttsModel,
ttsEndpointPreset: state.preferences.ttsEndpointPreset,
ttsEndpoint: state.preferences.ttsEndpoint,
familiarName: state.preferences.familiarName,
},
petScaleOptions,
openApiChat: getOpenApiChatSettingsSnapshot(),
};
}
export function getI18nSnapshot(): {
locale: Locale;
localePreference: LocalePreference;
availableLocales: { value: Locale; label: string }[];
messages: ReturnType<typeof getActiveMessages>;
} {
return {
locale: getActiveLocale(),
localePreference: getAppStateSnapshot().preferences.locale,
availableLocales: SUPPORTED_LOCALES.map((value) => ({ value, label: LOCALE_LABELS[value] })),
messages: getActiveMessages(),
};
}
export async function getDashboardSnapshot(): Promise<{
readonly defaultPet: { readonly id: string; readonly displayName: string; readonly previewSpriteUrl: string };
readonly installedPetCount: number;
readonly catalog: { readonly source: string; readonly total?: number; readonly page?: number; readonly pageCount?: number; readonly error?: string };
readonly plugins: { readonly installed: number; readonly enabled: number; readonly broken: number };
readonly updateStatus: ReturnType<typeof getUpdateStatus>;
readonly activity: ReturnType<typeof getAppStateSnapshot>["analytics"];
}> {
const state = getAppStateSnapshot();
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId && !familiar.broken) ?? state.familiars.installed[0];
const preview = await getDefaultPetPreviewSpriteInfo();
const catalog = await getCatalogUiState().catch((error: unknown) => ({ source: "error" as const, familiars: [], total: undefined, page: undefined, pageCount: undefined, error: error instanceof Error ? error.message : "Catalog unavailable." }));
const pluginSnapshot = await getPluginService().getSnapshot().catch((error: unknown) => {
warn("ui", "dashboard plugin snapshot unavailable", { error: error instanceof Error ? error.message : String(error) });
return { plugins: [] } as const;
});
const installedPlugins = pluginSnapshot.plugins.length;
const brokenPlugins = pluginSnapshot.plugins.filter((plugin) => Boolean(plugin.brokenReason)).length;
const enabledPlugins = pluginSnapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason).length;
return {
defaultPet: {
id: defaultPet?.id ?? state.preferences.defaultPetId,
displayName: defaultPet?.displayName ?? "FamiliarOS",
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
},
installedPetCount: state.familiars.installed.length,
catalog: {
source: catalog.source,
total: catalog.total,
page: catalog.page,
pageCount: catalog.pageCount,
error: catalog.error,
},
plugins: {
installed: installedPlugins,
enabled: enabledPlugins,
broken: brokenPlugins,
},
updateStatus: getUpdateStatus(),
activity: state.analytics,
};
}
export async function getReactionAnimationSettingsSnapshot(): Promise<unknown> {
const state = getAppStateSnapshot();
const preview = await getDefaultPetPreviewSpriteInfo();
return {
reactions: reactionAnimationMetadata.map((reaction) => ({
...reaction,
label: t(`settings.reaction.${reaction.id}.label`),
description: t(`settings.reaction.${reaction.id}.description`),
})),
animations: selectableAnimationMetadata.map((animation) => ({
...animation,
label: t(`settings.animation.${animation.id}.label`),
description: t(`settings.animation.${animation.id}.description`),
})),
sprite: defaultPetSprite,
overrides: state.preferences.reactionAnimationOverrides ?? {},
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
};
}
export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string; readonly version: string }> {
const state = getAppStateSnapshot();
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
const builtInPath = join(app.getAppPath(), "assets", defaultPetSprite.fileName);
const candidatePath = selected && !selected.broken && !selected.builtIn
? join(getInstalledPetDir(selected.id), "spritesheet.webp")
: builtInPath;
try {
const spritesheet = await stat(candidatePath);
if (spritesheet.isFile() && spritesheet.size > 0 && spritesheet.size <= 100 * 1024 * 1024) {
return { path: candidatePath, version: `${selected?.id ?? "builtin"}-${Math.round(spritesheet.mtimeMs)}-${spritesheet.size}` };
}
} catch {
// Fall back to the bundled familiar if an installed default disappears while Settings is open.
}
const fallback = await stat(builtInPath);
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
}
export function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } {
if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false };
return { supported: true, enabled: app.getLoginItemSettings().openAtLogin };
}
function isLaunchAtLoginSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}

View file

@ -1,57 +0,0 @@
import { type PetPluginBubbles } from "./familiar-window.js";
import {
PetBubbleArbiter,
type ActiveBubble,
type PetBubbleSink,
} from "./plugin-bubble-arbiter.js";
export interface DefaultPetBubbleRuntime {
readonly arbiter: PetBubbleArbiter;
getPluginBubbles(): PetPluginBubbles | null;
handleDismissed(dismissToken: string): boolean;
}
interface CreateDefaultPetBubbleRuntimeOptions {
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
readonly showDefaultPetForExternalEvent: () => void;
readonly refreshDefaultPetContent: () => void;
}
export function createDefaultPetBubbleRuntime(
options: CreateDefaultPetBubbleRuntimeOptions,
): DefaultPetBubbleRuntime {
let pluginTransientBubble: ActiveBubble | null = null;
let pluginPinnedBubble: ActiveBubble | null = null;
const sink: PetBubbleSink = {
present(slot, content) {
if (slot === "pinned") pluginPinnedBubble = content;
else pluginTransientBubble = content;
options.debug("plugin bubble slot", {
slot,
token: content?.token ?? null,
pluginId: content?.pluginId ?? null,
});
if (content) options.showDefaultPetForExternalEvent();
options.refreshDefaultPetContent();
},
};
const arbiter = new PetBubbleArbiter(sink);
return {
arbiter,
getPluginBubbles() {
if (!pluginTransientBubble && !pluginPinnedBubble) return null;
return {
transient: pluginTransientBubble,
pinned: pluginPinnedBubble,
};
},
handleDismissed(dismissToken: string): boolean {
if (!PetBubbleArbiter.isArbiterToken(dismissToken)) return false;
arbiter.handleDismissed(dismissToken);
return true;
},
};
}

View file

@ -1,98 +0,0 @@
import type { BrowserWindow } from "electron";
import {
clearDefaultPetDisplayState,
clearDefaultPetStatusBadge,
getDefaultPetDismissToken,
setDefaultPetStatusBadge,
setDefaultPetTransientDisplay,
type DefaultPetDisplayState,
} from "./default-familiar-controller-display.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import type { PetPluginBubbles, PetStatusBadgeReaction, PetTransientDisplay } from "./familiar-window.js";
interface DefaultPetBubbleDismissHandler {
handleDismissed: (dismissToken: string) => boolean;
}
interface DefaultPetDisplaySupportOptions {
readonly displayState: DefaultPetDisplayState;
readonly bubbleArbiter: DefaultPetBubbleDismissHandler;
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
readonly refreshContent: () => void;
readonly getWindow: () => BrowserWindow | null;
readonly getPaused: () => boolean;
readonly getPluginBubbles: () => PetPluginBubbles | null;
readonly loadContent: (
window: BrowserWindow,
paused: boolean,
display: PetTransientDisplay | null,
badge: PetStatusBadgeReaction | null,
dismissToken?: string,
pluginBubbles?: PetPluginBubbles | null,
) => Promise<void>;
readonly updateScalePreference: (scale: number) => void | Promise<void>;
}
export function createDefaultPetDisplaySupport(options: DefaultPetDisplaySupportOptions) {
function getDefaultPetDisplayDeps() {
return {
debug: options.debug,
refreshContent: options.refreshContent,
getWindowForReaction: () => options.getWindow(),
};
}
function setTransientDisplay(display: PetTransientDisplay): void {
setDefaultPetTransientDisplay(options.displayState, display, getDefaultPetDisplayDeps());
}
function setStatusBadge(reaction: FamiliarOSReaction): void {
setDefaultPetStatusBadge(options.displayState, reaction, getDefaultPetDisplayDeps());
}
function clearStatusBadge(): void {
clearDefaultPetStatusBadge(options.displayState, getDefaultPetDisplayDeps());
}
function clearDefaultPetDisplayTimers(): void {
clearDefaultPetDisplayState(options.displayState);
}
function getCurrentDismissToken(): string | undefined {
return getDefaultPetDismissToken(options.displayState);
}
function handleBubbleDismissed(dismissToken: string): void {
options.debug("bubble dismissed callback", { windowId: options.getWindow()?.id, dismissToken, currentGeneration: options.displayState.displayGeneration });
if (options.bubbleArbiter.handleDismissed(dismissToken)) {
return;
}
if (dismissToken !== String(options.displayState.displayGeneration)) {
options.debug("bubble dismissed stale token", { dismissToken, currentGeneration: options.displayState.displayGeneration });
return;
}
clearDefaultPetDisplayTimers();
const window = options.getWindow();
if (window) {
void options.loadContent(window, options.getPaused(), null, null, undefined, options.getPluginBubbles());
}
}
function handleScaleChanged(scale: number): void {
options.debug("scale changed", { scale });
void Promise.resolve(options.updateScalePreference(scale)).catch((error: unknown) => {
console.error("Failed to update familiar scale preference.", error);
});
}
return {
clearDefaultPetDisplayTimers,
clearStatusBadge,
getCurrentDismissToken,
handleBubbleDismissed,
handleScaleChanged,
setStatusBadge,
setTransientDisplay,
};
}

View file

@ -1,157 +0,0 @@
import { transientDisplayMs, type FamiliarOSReaction } from "./local-ipc-protocol.js";
import {
clearTransientReaction,
getTransientDisplayDurationMs,
getTransientReactionAnimationMs,
mergePetTransientDisplay,
setPetReactionState,
type PetStatusBadgeReaction,
type PetTransientDisplay,
} from "./familiar-window.js";
import type { FamiliarWindowRendererBridge } from "./familiar-window-renderer-bridge.js";
const busyStatusBadgeMs = 120_000;
type DisplayTimer = ReturnType<typeof setTimeout>;
type DisplayTimeoutFactory = (callback: () => void, ms: number) => DisplayTimer;
type DisplayTimeoutClearer = (timeout: DisplayTimer) => void;
export interface DefaultPetDisplayState {
transientDisplay: PetTransientDisplay | null;
statusBadge: PetStatusBadgeReaction | null;
transientDisplayTimeout: DisplayTimer | null;
transientAnimationTimeout: DisplayTimer | null;
statusBadgeTimeout: DisplayTimer | null;
displayGeneration: number;
}
interface DefaultPetDisplayDeps {
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
readonly refreshContent: () => void;
readonly scheduleTimeout?: DisplayTimeoutFactory;
readonly clearScheduledTimeout?: DisplayTimeoutClearer;
}
interface DefaultPetTransientDisplayDeps extends DefaultPetDisplayDeps {
readonly getWindowForReaction: () => FamiliarWindowRendererBridge | null;
}
export function createDefaultPetDisplayState(): DefaultPetDisplayState {
return {
transientDisplay: null,
statusBadge: null,
transientDisplayTimeout: null,
transientAnimationTimeout: null,
statusBadgeTimeout: null,
displayGeneration: 0,
};
}
export function setDefaultPetTransientDisplay(
state: DefaultPetDisplayState,
display: PetTransientDisplay,
deps: DefaultPetTransientDisplayDeps,
): void {
deps.debug("transient display set", {
reaction: display.reaction,
hasMessage: Boolean(display.message),
hasReactionMessage: Boolean(display.reactionMessage),
});
state.displayGeneration += 1;
state.transientDisplay = mergePetTransientDisplay(state.transientDisplay, {
...display,
dismissToken: String(state.displayGeneration),
});
if (display.reaction) {
setDefaultPetStatusBadge(state, display.reaction, deps);
}
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
const scheduleTimeout = deps.scheduleTimeout ?? setTimeout;
state.transientDisplayTimeout = clearDisplayTimer(state.transientDisplayTimeout, clearScheduledTimeout);
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
const animationMs = getTransientReactionAnimationMs(state.transientDisplay);
const displayDurationMs = getTransientDisplayDurationMs(state.transientDisplay);
if (animationMs !== null && animationMs < displayDurationMs) {
state.transientAnimationTimeout = scheduleTimeout(() => {
if (!state.transientDisplay) return;
state.transientDisplay = clearTransientReaction(state.transientDisplay);
state.transientAnimationTimeout = null;
const window = deps.getWindowForReaction();
if (window) {
setPetReactionState(window, "idle");
}
}, animationMs);
}
if (!state.transientDisplay.sticky) {
state.transientDisplayTimeout = scheduleTimeout(() => {
state.transientDisplay = null;
state.transientDisplayTimeout = null;
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
deps.refreshContent();
}, displayDurationMs);
}
deps.refreshContent();
}
export function setDefaultPetStatusBadge(
state: DefaultPetDisplayState,
reaction: FamiliarOSReaction,
deps: DefaultPetDisplayDeps,
): void {
if (reaction === "idle") {
clearDefaultPetStatusBadge(state, deps);
return;
}
state.statusBadge = reaction;
const timeoutMs = isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs;
deps.debug("status badge set", { reaction, durationMs: timeoutMs });
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
const scheduleTimeout = deps.scheduleTimeout ?? setTimeout;
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
state.statusBadgeTimeout = scheduleTimeout(() => {
clearDefaultPetStatusBadge(state, deps);
deps.refreshContent();
}, timeoutMs);
}
export function clearDefaultPetStatusBadge(state: DefaultPetDisplayState, deps: Pick<DefaultPetDisplayDeps, "debug" | "clearScheduledTimeout">): void {
if (state.statusBadge) {
deps.debug("status badge cleared", { reaction: state.statusBadge });
}
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
state.statusBadge = null;
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
}
export function clearDefaultPetDisplayState(
state: DefaultPetDisplayState,
deps: Pick<DefaultPetDisplayDeps, "clearScheduledTimeout"> = {},
): void {
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
state.transientDisplayTimeout = clearDisplayTimer(state.transientDisplayTimeout, clearScheduledTimeout);
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
state.transientDisplay = null;
state.statusBadge = null;
}
export function getDefaultPetDismissToken(state: DefaultPetDisplayState): string | undefined {
return state.transientDisplay?.dismissToken ?? (state.statusBadge ? String(state.displayGeneration) : undefined);
}
function clearDisplayTimer(timeout: DisplayTimer | null, clearScheduledTimeout: (timeout: DisplayTimer) => void): null {
if (timeout) {
clearScheduledTimeout(timeout);
}
return null;
}
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}

View file

@ -1,132 +0,0 @@
import type { BrowserWindow } from "electron";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
export function applyExternalPetReactionAction(
dependencies: {
readonly paused: boolean;
readonly setTransientDisplay: (display: { readonly reaction: FamiliarOSReaction; readonly suppressReactionMessage?: boolean }) => void;
readonly showDefaultPetForExternalEvent: () => void;
readonly isDefaultPetVisible: () => boolean;
},
reaction: FamiliarOSReaction,
options: { readonly showMessage?: boolean } = {},
): { readonly shown: boolean; readonly reason?: string } {
if (dependencies.paused) {
return { shown: false, reason: "paused" };
}
dependencies.setTransientDisplay({ reaction, ...(options.showMessage === false ? { suppressReactionMessage: true } : {}) });
dependencies.showDefaultPetForExternalEvent();
return { shown: dependencies.isDefaultPetVisible() };
}
export function applyExternalPetSayAction(
dependencies: {
readonly paused: boolean;
readonly clearStatusBadge: () => void;
readonly setTransientDisplay: (display: { readonly message: string; readonly reaction?: FamiliarOSReaction }) => void;
readonly showDefaultPetForExternalEvent: () => void;
readonly isDefaultPetVisible: () => boolean;
},
message: string,
reaction?: FamiliarOSReaction,
): { readonly shown: boolean; readonly reason?: string } {
if (dependencies.paused) {
return { shown: false, reason: "paused" };
}
if (!reaction) dependencies.clearStatusBadge();
dependencies.setTransientDisplay({ message, reaction });
dependencies.showDefaultPetForExternalEvent();
return { shown: dependencies.isDefaultPetVisible() };
}
export function applyInternalPetMessageAction(
dependencies: {
readonly paused: boolean;
readonly clearStatusBadge: () => void;
readonly setTransientDisplay: (display: {
readonly message: string;
readonly reaction?: FamiliarOSReaction;
readonly fullMessage?: boolean;
readonly sticky?: boolean;
}) => void;
readonly showDefaultPet: () => void;
readonly isDefaultPetVisible: () => boolean;
},
message: string,
options: {
readonly reaction?: FamiliarOSReaction;
readonly fullMessage?: boolean;
readonly sticky?: boolean;
} = {},
): { readonly shown: boolean; readonly reason?: string } {
if (dependencies.paused) {
return { shown: false, reason: "paused" };
}
if (!options.reaction) dependencies.clearStatusBadge();
dependencies.setTransientDisplay({
message,
reaction: options.reaction,
fullMessage: options.fullMessage,
sticky: options.sticky,
});
dependencies.showDefaultPet();
return { shown: dependencies.isDefaultPetVisible() };
}
export function applyExternalPetStatusReactionAction(
dependencies: {
readonly clearStatusBadge: () => void;
readonly setStatusBadge: (reaction: FamiliarOSReaction) => void;
readonly refreshDefaultPetContent: () => void;
},
reaction: FamiliarOSReaction | null,
): void {
if (reaction === null || reaction === "idle") dependencies.clearStatusBadge();
else dependencies.setStatusBadge(reaction);
dependencies.refreshDefaultPetContent();
}
export function applyExternalPetMoveByAction(
options: { readonly x: number; readonly y: number; readonly durationMs?: number },
moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>,
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
return moveDefaultPetBy(Number(options.x), Number(options.y), options.durationMs);
}
export function applyExternalPetWanderAction(
options: { readonly distance?: number; readonly durationMs?: number },
maxPluginMoveDistance: number,
moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>,
random: () => number = Math.random,
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
const requestedDistance = Number(options.distance ?? 80);
const distance = Number.isFinite(requestedDistance) ? Math.min(Math.max(requestedDistance, 0), maxPluginMoveDistance) : 0;
const angle = random() * Math.PI * 2;
return moveDefaultPetBy(Math.cos(angle) * distance, Math.sin(angle) * distance, options.durationMs);
}
export function applyExternalPetMoveToHomeAction(
dependencies: {
readonly getWindow: () => BrowserWindow | null;
readonly readWindowPosition: (window: BrowserWindow) => { readonly x: number; readonly y: number };
readonly getSafeDefaultPetPosition: (position: { readonly x: number; readonly y: number }) => { readonly x: number; readonly y: number };
readonly moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>;
readonly maxPluginMoveDurationMs: number;
},
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
const window = dependencies.getWindow();
if (!window || window.isDestroyed()) return Promise.resolve({ moved: false, reason: "no-window" });
const current = dependencies.readWindowPosition(window);
const home = dependencies.getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
return dependencies.moveDefaultPetBy(
home.x - current.x,
home.y - current.y,
dependencies.maxPluginMoveDurationMs,
Number.POSITIVE_INFINITY,
);
}

View file

@ -1,87 +0,0 @@
import type { BrowserWindow } from "electron";
export type PetPosition = { readonly x: number; readonly y: number };
type MoveResult = { readonly moved: boolean; readonly reason?: string };
export interface DefaultPetMotionWindow extends Pick<BrowserWindow, "id" | "isDestroyed" | "isVisible" | "setPosition"> {}
export interface DefaultPetMotionBlockOptions<WindowType extends DefaultPetMotionWindow = DefaultPetMotionWindow> {
readonly window: WindowType;
readonly allowMoving?: boolean;
readonly movementInProgress: boolean;
readonly paused: boolean;
readonly hasTransientDisplay: boolean;
readonly hasStatusBadge: boolean;
readonly isPetWindowDragging: (window: WindowType) => boolean;
}
export interface MoveDefaultPetWindowOptions<WindowType extends DefaultPetMotionWindow = DefaultPetMotionWindow> extends DefaultPetMotionBlockOptions<WindowType> {
readonly rawX: number;
readonly rawY: number;
readonly rawDurationMs: unknown;
readonly maxDistance: number;
readonly minDurationMs: number;
readonly maxDurationMs: number;
readonly readWindowPosition: (window: WindowType) => PetPosition;
readonly getSafeDefaultPetPosition: (position: PetPosition) => PetPosition;
readonly persistDefaultPetPosition: (position: PetPosition) => void;
readonly setMovementInProgress: (value: boolean) => void;
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
readonly delayMs?: (ms: number) => Promise<void>;
}
export function getPetMovementBlockedReason<WindowType extends DefaultPetMotionWindow>(options: DefaultPetMotionBlockOptions<WindowType>): string | undefined {
if (options.movementInProgress && !options.allowMoving) return "already-moving";
if (!options.window.isVisible()) return "hidden";
if (options.paused) return "paused";
if (options.isPetWindowDragging(options.window)) return "dragging";
if (options.hasTransientDisplay) return "transient-display";
if (options.hasStatusBadge) return "status-active";
return undefined;
}
export async function moveDefaultPetWindowBy<WindowType extends DefaultPetMotionWindow>(options: MoveDefaultPetWindowOptions<WindowType>): Promise<MoveResult> {
const blockedReason = getPetMovementBlockedReason(options);
if (blockedReason) {
options.debug("move skipped", { reason: blockedReason });
return { moved: false, reason: blockedReason };
}
const current = options.readWindowPosition(options.window);
const distance = Math.min(Math.hypot(options.rawX, options.rawY), options.maxDistance);
if (!Number.isFinite(distance) || distance <= 0) return { moved: false, reason: "invalid-distance" };
const scale = distance / Math.hypot(options.rawX, options.rawY);
const target = options.getSafeDefaultPetPosition({ x: current.x + options.rawX * scale, y: current.y + options.rawY * scale });
const durationMs = clampNumber(Number(options.rawDurationMs ?? 700), options.minDurationMs, options.maxDurationMs);
const steps = Math.max(8, Math.min(16, Math.round(durationMs / 100)));
const delayMs = options.delayMs ?? delay;
options.setMovementInProgress(true);
options.debug("move start", { windowId: options.window.id, from: current, target, durationMs, steps });
try {
for (let step = 1; step <= steps; step += 1) {
if (options.window.isDestroyed()) return { moved: false, reason: "destroyed" };
const blocked = getPetMovementBlockedReason({ ...options, allowMoving: true });
if (blocked) return { moved: false, reason: blocked };
const t = step / steps;
options.window.setPosition(Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false);
await delayMs(durationMs / steps);
}
options.window.setPosition(target.x, target.y, false);
options.persistDefaultPetPosition(target);
options.debug("move finished", { windowId: options.window.id, target });
return { moved: true };
} finally {
options.setMovementInProgress(false);
}
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -1,154 +0,0 @@
import { powerMonitor, screen, type BrowserWindow } from "electron";
import { resetDefaultPetPosition, setDefaultPetPosition } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import {
getSafeDefaultPetPosition,
loadDefaultPetContent,
readWindowPosition,
recoverPetMouseInterop,
type PetPluginBubbles,
type PetStatusBadgeReaction,
type PetTransientDisplay,
} from "./familiar-window.js";
import { debug, info } from "./logger.js";
export interface DefaultPetWindowContentSnapshot {
readonly paused: boolean;
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly dismissToken?: string;
readonly pluginBubbles: PetPluginBubbles | null;
}
export function loadDefaultPetWindowContent(
window: BrowserWindow,
snapshot: DefaultPetWindowContentSnapshot,
): void {
void loadDefaultPetContent(
window,
snapshot.paused,
snapshot.display,
snapshot.badge,
snapshot.dismissToken,
snapshot.pluginBubbles,
);
}
export function hideManagedDefaultPetWindow(
window: BrowserWindow | null,
petId: string | undefined,
): void {
if (!window || window.isDestroyed()) {
debug("familiar.default", "hide skipped", { reason: "no-window" });
return;
}
info("familiar.default", "hide requested", {
windowId: window.id,
position: readWindowPosition(window),
petId,
});
setDefaultPetPosition(readWindowPosition(window));
window.hide();
}
export function recoverManagedDefaultPetMouseInterop(
window: BrowserWindow | null,
reason: string,
petId: string | undefined,
): void {
if (!window || window.isDestroyed()) {
debug("familiar.default", "mouse interop recovery skipped", {
reason,
skippedReason: "no-window",
});
return;
}
debug("familiar.default", "mouse interop recovery requested", {
windowId: window.id,
reason,
petId,
});
recoverPetMouseInterop(window, reason);
}
export function destroyManagedDefaultPetWindow(
window: BrowserWindow | null,
petId: string | undefined,
clearDisplayTimers: () => void,
): BrowserWindow | null {
clearDisplayTimers();
if (!window || window.isDestroyed()) {
debug("familiar.default", "destroy skipped", { reason: "no-window" });
return null;
}
info("familiar.default", "destroy requested", {
windowId: window.id,
position: readWindowPosition(window),
petId,
});
setDefaultPetPosition(readWindowPosition(window));
window.setIgnoreMouseEvents(false);
window.destroy();
return null;
}
export function installDefaultPetWindowDisplayHandlers(
reclampWindow: () => void,
recoverAfterResume: () => void,
): void {
screen.on("display-added", reclampWindow);
screen.on("display-removed", reclampWindow);
screen.on("display-metrics-changed", reclampWindow);
powerMonitor.on("resume", recoverAfterResume);
}
export function getManagedDefaultPetWindowBounds(
window: BrowserWindow | null,
): Electron.Rectangle | null {
if (!window || window.isDestroyed()) return null;
return window.getBounds();
}
export function reclampManagedDefaultPetWindow(
window: BrowserWindow | null,
recoverMouseInterop: (reason: string) => void,
): void {
if (!window || window.isDestroyed()) {
return;
}
const safePosition = readWindowPosition(window);
info("familiar.default", "reclamp position", {
windowId: window.id,
position: safePosition,
});
window.setPosition(safePosition.x, safePosition.y, false);
setDefaultPetPosition(safePosition);
recoverMouseInterop("display-change");
}
export function recoverManagedDefaultPetWindowAfterResume(
recoverMouseInterop: (reason: string) => void,
scheduleTimeout: typeof setTimeout = setTimeout,
): void {
recoverMouseInterop("power-resume");
scheduleTimeout(() => recoverMouseInterop("power-resume+500ms"), 500).unref?.();
}
export function resetManagedDefaultPetWindowToInitialPosition(
window: BrowserWindow | null,
): void {
const safePosition = getSafeDefaultPetPosition(
getDefaultPetInitialPosition(defaultPetWindowSize),
);
resetDefaultPetPosition(safePosition);
if (window && !window.isDestroyed()) {
window.setPosition(safePosition.x, safePosition.y, false);
}
}

View file

@ -1,336 +0,0 @@
import { BrowserWindow } from "electron";
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js";
import {
createDefaultPetDisplayState,
} from "./default-familiar-controller-display.js";
import { createDefaultPetDisplaySupport } from "./default-familiar-controller-display-support.js";
import {
applyExternalPetMoveByAction,
applyExternalPetMoveToHomeAction,
applyExternalPetReactionAction,
applyExternalPetSayAction,
applyExternalPetStatusReactionAction,
applyExternalPetWanderAction,
applyInternalPetMessageAction,
} from "./default-familiar-controller-external.js";
import { getPetMovementBlockedReason, moveDefaultPetWindowBy } from "./default-familiar-controller-motion.js";
import { createDefaultPetBubbleRuntime } from "./default-familiar-controller-bubbles.js";
import {
destroyManagedDefaultPetWindow,
getManagedDefaultPetWindowBounds,
hideManagedDefaultPetWindow,
installDefaultPetWindowDisplayHandlers,
loadDefaultPetWindowContent,
reclampManagedDefaultPetWindow,
recoverManagedDefaultPetMouseInterop,
recoverManagedDefaultPetWindowAfterResume,
resetManagedDefaultPetWindowToInitialPosition,
} from "./default-familiar-controller-window.js";
import { debug, info } from "./logger.js";
import { type FamiliarOSReaction } from "./local-ipc-protocol.js";
import { createDefaultPetWindow, getSafeDefaultPetPosition, isPetWindowDragging, loadDefaultPetContent, readWindowPosition, type PetPluginBubbles, type PetTransientDisplay } from "./familiar-window.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
let defaultPetWindow: BrowserWindow | null = null;
let paused = false;
const displayState = createDefaultPetDisplayState();
const maxPluginMoveDistance = 160;
const minPluginMoveDurationMs = 250;
const maxPluginMoveDurationMs = 1_500;
let movementInProgress = false;
let promptRequestHandler: (() => void) | undefined;
export type PetMoveOptions = { readonly x: number; readonly y: number; readonly durationMs?: number };
export type PetWanderOptions = { readonly distance?: number; readonly durationMs?: number };
export type InternalPetMessageOptions = {
readonly reaction?: FamiliarOSReaction;
readonly fullMessage?: boolean;
readonly sticky?: boolean;
};
export type PetReactionOptions = { readonly showMessage?: boolean };
const defaultPetBubbleRuntime = createDefaultPetBubbleRuntime({
debug: (message, fields) => debug("familiar.default", message, fields),
showDefaultPetForExternalEvent: () => showDefaultPetForExternalEvent(),
refreshDefaultPetContent: () => refreshDefaultPetContent(),
});
const defaultPetDisplaySupport = createDefaultPetDisplaySupport({
displayState,
bubbleArbiter: defaultPetBubbleRuntime,
debug: (message, fields) => debug("familiar.default", message, fields),
refreshContent: () => refreshDefaultPetContent(),
getWindow: () => (defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null),
getPaused: () => paused,
getPluginBubbles: () => getDefaultPetPluginBubbles(),
loadContent: (window, pausedState, display, badge, dismissToken, pluginBubbles) => loadDefaultPetContent(window, pausedState, display, badge, dismissToken, pluginBubbles),
updateScalePreference: (scale) => import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}),
});
const {
clearDefaultPetDisplayTimers,
clearStatusBadge,
getCurrentDismissToken,
handleBubbleDismissed,
handleScaleChanged,
setStatusBadge,
setTransientDisplay,
} = defaultPetDisplaySupport;
/** The default familiar's bubble arbiter — the Electron bubbles capability targets this. */
export const defaultPetBubbleArbiter = defaultPetBubbleRuntime.arbiter;
export function getDefaultPetPluginBubbles(): PetPluginBubbles | null {
return defaultPetBubbleRuntime.getPluginBubbles();
}
export function showDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: true });
showDefaultPetWindow("user");
}
function showDefaultPetWindow(source: "user" | "external-event"): void {
const window = getOrCreateDefaultPetWindow();
info("familiar.default", "show requested", { source, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), paused, petId: getAppStateSnapshot().preferences.defaultPetId });
if (window.isMinimized()) {
window.restore();
}
window.showInactive();
}
export function hideDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: false });
hideManagedDefaultPetWindow(
defaultPetWindow,
getAppStateSnapshot().preferences.defaultPetId,
);
}
export function isDefaultPetVisible(): boolean {
return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible());
}
export function setDefaultPetPaused(nextPaused: boolean): void {
paused = nextPaused;
info("familiar.default", "pause changed", { paused });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
loadDefaultPetWindowContent(defaultPetWindow, {
paused,
display: displayState.transientDisplay,
badge: displayState.statusBadge,
dismissToken: getCurrentDismissToken(),
pluginBubbles: getDefaultPetPluginBubbles(),
});
}
export function getDefaultPetPaused(): boolean {
return paused;
}
export function getDefaultPetWindowForPlugins(): BrowserWindow | null {
return defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null;
}
export function refreshDefaultPetContent(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("familiar.default", "refresh skipped", { reason: "no-window" });
return;
}
debug("familiar.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(displayState.transientDisplay), badge: displayState.statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId });
loadDefaultPetWindowContent(defaultPetWindow, {
paused,
display: displayState.transientDisplay,
badge: displayState.statusBadge,
dismissToken: getCurrentDismissToken(),
pluginBubbles: getDefaultPetPluginBubbles(),
});
}
export function recoverDefaultPetMouseInterop(reason: string): void {
recoverManagedDefaultPetMouseInterop(
defaultPetWindow,
reason,
getAppStateSnapshot().preferences.defaultPetId,
);
}
export function applyExternalPetReaction(reaction: FamiliarOSReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } {
return applyExternalPetReactionAction({
paused,
setTransientDisplay,
showDefaultPetForExternalEvent,
isDefaultPetVisible,
}, reaction, options);
}
export function applyExternalPetSay(message: string, reaction?: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
return applyExternalPetSayAction({
paused,
clearStatusBadge,
setTransientDisplay,
showDefaultPetForExternalEvent,
isDefaultPetVisible,
}, message, reaction);
}
export function applyInternalPetMessage(message: string, options: InternalPetMessageOptions = {}): { readonly shown: boolean; readonly reason?: string } {
return applyInternalPetMessageAction({
paused,
clearStatusBadge,
setTransientDisplay,
showDefaultPet,
isDefaultPetVisible,
}, message, options);
}
export function applyExternalPetStatusReaction(reaction: FamiliarOSReaction | null): void {
applyExternalPetStatusReactionAction({
clearStatusBadge,
setStatusBadge,
refreshDefaultPetContent,
}, reaction);
}
export function applyExternalPetMoveBy(options: PetMoveOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
return applyExternalPetMoveByAction(options, moveDefaultPetBy);
}
export function applyExternalPetWander(options: PetWanderOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
return applyExternalPetWanderAction(options, maxPluginMoveDistance, moveDefaultPetBy);
}
export function applyExternalPetMoveToHome(): Promise<{ readonly moved: boolean; readonly reason?: string }> {
return applyExternalPetMoveToHomeAction({
getWindow: () => defaultPetWindow,
readWindowPosition,
getSafeDefaultPetPosition,
moveDefaultPetBy,
maxPluginMoveDurationMs,
});
}
export function destroyDefaultPet(): void {
defaultPetWindow = destroyManagedDefaultPetWindow(
defaultPetWindow,
getAppStateSnapshot().preferences.defaultPetId,
clearDefaultPetDisplayTimers,
);
}
export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void {
promptRequestHandler = options.onPromptRequested;
installDefaultPetWindowDisplayHandlers(
reclampDefaultPetWindow,
recoverDefaultPetWindowAfterResume,
);
}
export function getDefaultPetWindowBounds(): Electron.Rectangle | null {
return getManagedDefaultPetWindowBounds(defaultPetWindow);
}
function getOrCreateDefaultPetWindow(): BrowserWindow {
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
return defaultPetWindow;
}
const position = getSafeDefaultPetPosition(getDefaultPetPosition());
defaultPetWindow = createDefaultPetWindow({
position,
paused,
display: displayState.transientDisplay,
badge: displayState.statusBadge,
pluginBubbles: getDefaultPetPluginBubbles(),
onPositionChanged: setDefaultPetPosition,
onHideRequested: hideDefaultPet,
onBubbleDismissed: handleBubbleDismissed,
onPromptRequested: () => promptRequestHandler?.(),
onScaleChanged: handleScaleChanged,
onBubbleAction: (token, actionId) => defaultPetBubbleArbiter.handleAction(token, actionId),
onBubbleSubmit: (token, values) => defaultPetBubbleArbiter.handleSubmit(token, values),
onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload),
}, getCurrentDismissToken());
const windowId = defaultPetWindow.id;
info("familiar.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
defaultPetWindow.on("closed", () => {
info("familiar.default", "closed", { windowId });
defaultPetWindow = null;
});
return defaultPetWindow;
}
function showDefaultPetForExternalEvent(): void {
const state = getAppStateSnapshot();
const visible = isDefaultPetVisible();
if (!shouldShowDefaultPetForExternalEvent(visible, state.preferences.openDefaultPetOnLaunch, paused)) {
debug("familiar.default", "external show skipped", { reason: "paused", visible, openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch });
return;
}
showDefaultPetWindow("external-event");
}
async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unknown, maxDistance = maxPluginMoveDistance): Promise<{ readonly moved: boolean; readonly reason?: string }> {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return { moved: false, reason: "no-window" };
return moveDefaultPetWindowBy({
window: defaultPetWindow,
rawX,
rawY,
rawDurationMs,
maxDistance,
minDurationMs: minPluginMoveDurationMs,
maxDurationMs: maxPluginMoveDurationMs,
movementInProgress,
paused,
hasTransientDisplay: Boolean(displayState.transientDisplay),
hasStatusBadge: Boolean(displayState.statusBadge),
readWindowPosition,
getSafeDefaultPetPosition,
persistDefaultPetPosition: setDefaultPetPosition,
isPetWindowDragging,
setMovementInProgress: (value) => {
movementInProgress = value;
},
debug: (message, fields) => debug("familiar.default", message, fields),
});
}
function getMovementBlockedReason(window: BrowserWindow, allowMoving = false): string | undefined {
return getPetMovementBlockedReason({
window,
allowMoving,
movementInProgress,
paused,
hasTransientDisplay: Boolean(displayState.transientDisplay),
hasStatusBadge: Boolean(displayState.statusBadge),
isPetWindowDragging,
});
}
function reclampDefaultPetWindow(): void {
reclampManagedDefaultPetWindow(defaultPetWindow, recoverDefaultPetMouseInterop);
}
function recoverDefaultPetWindowAfterResume(): void {
recoverManagedDefaultPetWindowAfterResume(recoverDefaultPetMouseInterop);
}
export function shouldOpenDefaultPetOnLaunch(): boolean {
return getAppStateSnapshot().preferences.openDefaultPetOnLaunch;
}
export function resetDefaultPetToInitialPosition(): void {
resetManagedDefaultPetWindowToInitialPosition(defaultPetWindow);
}

View file

@ -0,0 +1,454 @@
import { BrowserWindow, powerMonitor, screen } from "electron";
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { debug, info } from "./logger.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, isPetWindowDragging, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, type PetPluginBubbles, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
import { PetBubbleArbiter, type ActiveBubble, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
let defaultPetWindow: BrowserWindow | null = null;
let paused = false;
let transientDisplay: PetTransientDisplay | null = null;
let statusBadge: PetStatusBadgeReaction | null = null;
let transientDisplayTimeout: NodeJS.Timeout | null = null;
let transientAnimationTimeout: NodeJS.Timeout | null = null;
let statusBadgeTimeout: NodeJS.Timeout | null = null;
let displayGeneration = 0;
const busyStatusBadgeMs = 120_000;
const maxPluginMoveDistance = 160;
const minPluginMoveDurationMs = 250;
const maxPluginMoveDurationMs = 1_500;
let movementInProgress = false;
let promptRequestHandler: (() => void) | undefined;
export type PetMoveOptions = { readonly x: number; readonly y: number; readonly durationMs?: number };
export type PetWanderOptions = { readonly distance?: number; readonly durationMs?: number };
export type InternalPetMessageOptions = {
readonly reaction?: OpenPetsReaction;
readonly fullMessage?: boolean;
readonly sticky?: boolean;
};
export type PetReactionOptions = { readonly showMessage?: boolean };
// Plugin bubble slots (SDK v3): the arbiter decides what each slot shows; the
// sink merges its decisions into the default pet render.
let pluginTransientBubble: ActiveBubble | null = null;
let pluginPinnedBubble: ActiveBubble | null = null;
const defaultPetBubbleSink: PetBubbleSink = {
present(slot, content) {
if (slot === "pinned") pluginPinnedBubble = content;
else pluginTransientBubble = content;
debug("pet.default", "plugin bubble slot", { slot, token: content?.token ?? null, pluginId: content?.pluginId });
if (content) showDefaultPetForExternalEvent();
refreshDefaultPetContent();
},
};
/** The default pet's bubble arbiter — the Electron bubbles capability targets this. */
export const defaultPetBubbleArbiter = new PetBubbleArbiter(defaultPetBubbleSink);
export function getDefaultPetPluginBubbles(): PetPluginBubbles | null {
if (!pluginTransientBubble && !pluginPinnedBubble) return null;
return { transient: pluginTransientBubble, pinned: pluginPinnedBubble };
}
export function showDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: true });
showDefaultPetWindow("user");
}
function showDefaultPetWindow(source: "user" | "external-event"): void {
const window = getOrCreateDefaultPetWindow();
info("pet.default", "show requested", { source, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), paused, petId: getAppStateSnapshot().preferences.defaultPetId });
if (window.isMinimized()) {
window.restore();
}
window.showInactive();
}
export function hideDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: false });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "hide skipped", { reason: "no-window" });
return;
}
info("pet.default", "hide requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
defaultPetWindow.hide();
}
export function isDefaultPetVisible(): boolean {
return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible());
}
export function setDefaultPetPaused(nextPaused: boolean): void {
paused = nextPaused;
info("pet.default", "pause changed", { paused });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles());
}
export function getDefaultPetPaused(): boolean {
return paused;
}
export function getDefaultPetWindowForPlugins(): BrowserWindow | null {
return defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null;
}
export function refreshDefaultPetContent(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "refresh skipped", { reason: "no-window" });
return;
}
debug("pet.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(transientDisplay), badge: statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId });
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles());
}
export function recoverDefaultPetMouseInterop(reason: string): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "mouse interop recovery skipped", { reason, skippedReason: "no-window" });
return;
}
debug("pet.default", "mouse interop recovery requested", { windowId: defaultPetWindow.id, reason, petId: getAppStateSnapshot().preferences.defaultPetId });
recoverPetMouseInterop(defaultPetWindow, reason);
}
export function applyExternalPetReaction(reaction: OpenPetsReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
setTransientDisplay({ reaction, ...(options.showMessage === false ? { suppressReactionMessage: true } : {}) });
showDefaultPetForExternalEvent();
return { shown: isDefaultPetVisible() };
}
export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
if (!reaction) clearStatusBadge();
setTransientDisplay({ message, reaction });
showDefaultPetForExternalEvent();
return { shown: isDefaultPetVisible() };
}
export function applyInternalPetMessage(message: string, options: InternalPetMessageOptions = {}): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
if (!options.reaction) clearStatusBadge();
setTransientDisplay({
message,
reaction: options.reaction,
fullMessage: options.fullMessage,
sticky: options.sticky,
});
showDefaultPet();
return { shown: isDefaultPetVisible() };
}
export function applyExternalPetStatusReaction(reaction: OpenPetsReaction | null): void {
if (reaction === null || reaction === "idle") clearStatusBadge();
else setStatusBadge(reaction);
refreshDefaultPetContent();
}
export function applyExternalPetMoveBy(options: PetMoveOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
return moveDefaultPetBy(Number(options.x), Number(options.y), options.durationMs);
}
export function applyExternalPetWander(options: PetWanderOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
const distance = clampNumber(Number(options.distance ?? 80), 0, maxPluginMoveDistance);
const angle = Math.random() * Math.PI * 2;
return moveDefaultPetBy(Math.cos(angle) * distance, Math.sin(angle) * distance, options.durationMs);
}
export function applyExternalPetMoveToHome(): Promise<{ readonly moved: boolean; readonly reason?: string }> {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return Promise.resolve({ moved: false, reason: "no-window" });
const current = readWindowPosition(defaultPetWindow);
const home = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
return moveDefaultPetBy(home.x - current.x, home.y - current.y, maxPluginMoveDurationMs, Number.POSITIVE_INFINITY);
}
export function destroyDefaultPet(): void {
clearDefaultPetDisplayTimers();
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "destroy skipped", { reason: "no-window" });
defaultPetWindow = null;
return;
}
info("pet.default", "destroy requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
const window = defaultPetWindow;
defaultPetWindow = null;
window.setIgnoreMouseEvents(false);
window.destroy();
}
export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void {
promptRequestHandler = options.onPromptRequested;
screen.on("display-added", reclampDefaultPetWindow);
screen.on("display-removed", reclampDefaultPetWindow);
screen.on("display-metrics-changed", reclampDefaultPetWindow);
powerMonitor.on("resume", recoverDefaultPetWindowAfterResume);
}
export function getDefaultPetWindowBounds(): Electron.Rectangle | null {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return null;
return defaultPetWindow.getBounds();
}
function handleBubbleDismissed(dismissToken: string): void {
debug("pet.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayGeneration });
if (PetBubbleArbiter.isArbiterToken(dismissToken)) {
defaultPetBubbleArbiter.handleDismissed(dismissToken);
return;
}
if (dismissToken !== String(displayGeneration)) {
debug("pet.default", "bubble dismissed stale token", { dismissToken, currentGeneration: displayGeneration });
return;
}
clearDefaultPetDisplayTimers();
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
void loadDefaultPetContent(defaultPetWindow, paused, null, null, undefined, getDefaultPetPluginBubbles());
}
}
function handleScaleChanged(scale: number): void {
debug("pet.default", "scale changed", { scale });
import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}).catch((error) => {
console.error("Failed to update pet scale preference.", error);
});
}
function getOrCreateDefaultPetWindow(): BrowserWindow {
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
return defaultPetWindow;
}
const position = getSafeDefaultPetPosition(getDefaultPetPosition());
defaultPetWindow = createDefaultPetWindow({
position,
paused,
display: transientDisplay,
badge: statusBadge,
pluginBubbles: getDefaultPetPluginBubbles(),
onPositionChanged: setDefaultPetPosition,
onHideRequested: hideDefaultPet,
onBubbleDismissed: handleBubbleDismissed,
onPromptRequested: () => promptRequestHandler?.(),
onScaleChanged: handleScaleChanged,
onBubbleAction: (token, actionId) => defaultPetBubbleArbiter.handleAction(token, actionId),
onBubbleSubmit: (token, values) => defaultPetBubbleArbiter.handleSubmit(token, values),
onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload),
}, getCurrentDismissToken());
const windowId = defaultPetWindow.id;
info("pet.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
defaultPetWindow.on("closed", () => {
info("pet.default", "closed", { windowId });
defaultPetWindow = null;
});
return defaultPetWindow;
}
function setTransientDisplay(display: PetTransientDisplay): void {
debug("pet.default", "transient display set", { reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
displayGeneration++;
transientDisplay = mergePetTransientDisplay(transientDisplay, { ...display, dismissToken: String(displayGeneration) });
if (display.reaction) setStatusBadge(display.reaction);
if (transientDisplayTimeout) {
clearTimeout(transientDisplayTimeout);
}
if (transientAnimationTimeout) {
clearTimeout(transientAnimationTimeout);
transientAnimationTimeout = null;
}
const animationMs = getTransientReactionAnimationMs(transientDisplay);
const displayDurationMs = getTransientDisplayDurationMs(transientDisplay);
if (animationMs !== null && animationMs < displayDurationMs) {
transientAnimationTimeout = setTimeout(() => {
if (!transientDisplay) return;
transientDisplay = clearTransientReaction(transientDisplay);
transientAnimationTimeout = null;
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) setPetReactionState(defaultPetWindow, "idle");
}, animationMs);
}
if (!transientDisplay.sticky) {
transientDisplayTimeout = setTimeout(() => {
transientDisplay = null;
transientDisplayTimeout = null;
if (transientAnimationTimeout) {
clearTimeout(transientAnimationTimeout);
transientAnimationTimeout = null;
}
refreshDefaultPetContent();
}, displayDurationMs);
}
refreshDefaultPetContent();
}
function showDefaultPetForExternalEvent(): void {
const state = getAppStateSnapshot();
const visible = isDefaultPetVisible();
if (!shouldShowDefaultPetForExternalEvent(visible, state.preferences.openDefaultPetOnLaunch, paused)) {
debug("pet.default", "external show skipped", { reason: "paused", visible, openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch });
return;
}
showDefaultPetWindow("external-event");
}
async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unknown, maxDistance = maxPluginMoveDistance): Promise<{ readonly moved: boolean; readonly reason?: string }> {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return { moved: false, reason: "no-window" };
const window = defaultPetWindow;
const blockedReason = getMovementBlockedReason(window);
if (blockedReason) {
debug("pet.default", "move skipped", { reason: blockedReason });
return { moved: false, reason: blockedReason };
}
const current = readWindowPosition(window);
const distance = Math.min(Math.hypot(rawX, rawY), maxDistance);
if (!Number.isFinite(distance) || distance <= 0) return { moved: false, reason: "invalid-distance" };
const scale = distance / Math.hypot(rawX, rawY);
const target = getSafeDefaultPetPosition({ x: current.x + rawX * scale, y: current.y + rawY * scale });
const durationMs = clampNumber(Number(rawDurationMs ?? 700), minPluginMoveDurationMs, maxPluginMoveDurationMs);
const steps = Math.max(8, Math.min(16, Math.round(durationMs / 100)));
movementInProgress = true;
debug("pet.default", "move start", { windowId: window.id, from: current, target, durationMs, steps });
try {
for (let step = 1; step <= steps; step += 1) {
if (window.isDestroyed()) return { moved: false, reason: "destroyed" };
const blocked = getMovementBlockedReason(window, true);
if (blocked) return { moved: false, reason: blocked };
const t = step / steps;
window.setPosition(Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false);
await delay(durationMs / steps);
}
window.setPosition(target.x, target.y, false);
setDefaultPetPosition(target);
debug("pet.default", "move finished", { windowId: window.id, target });
return { moved: true };
} finally {
movementInProgress = false;
}
}
function getMovementBlockedReason(window: BrowserWindow, allowMoving = false): string | undefined {
if (movementInProgress && !allowMoving) return "already-moving";
if (!window.isVisible()) return "hidden";
if (paused) return "paused";
if (isPetWindowDragging(window)) return "dragging";
if (transientDisplay) return "transient-display";
if (statusBadge) return "status-active";
return undefined;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setStatusBadge(reaction: OpenPetsReaction): void {
if (reaction === "idle") {
clearStatusBadge();
return;
}
statusBadge = reaction;
debug("pet.default", "status badge set", { reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = setTimeout(() => {
clearStatusBadge();
refreshDefaultPetContent();
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
}
function clearStatusBadge(): void {
if (statusBadge) debug("pet.default", "status badge cleared", { reaction: statusBadge });
statusBadge = null;
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = null;
}
function clearDefaultPetDisplayTimers(): void {
if (transientDisplayTimeout) clearTimeout(transientDisplayTimeout);
if (transientAnimationTimeout) clearTimeout(transientAnimationTimeout);
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
transientDisplayTimeout = null;
transientAnimationTimeout = null;
statusBadgeTimeout = null;
transientDisplay = null;
statusBadge = null;
}
function getCurrentDismissToken(): string | undefined {
return transientDisplay?.dismissToken ?? (statusBadge ? String(displayGeneration) : undefined);
}
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}
function reclampDefaultPetWindow(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
}
const safePosition = readWindowPosition(defaultPetWindow);
info("pet.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition });
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
setDefaultPetPosition(safePosition);
recoverDefaultPetMouseInterop("display-change");
}
function recoverDefaultPetWindowAfterResume(): void {
recoverDefaultPetMouseInterop("power-resume");
setTimeout(() => recoverDefaultPetMouseInterop("power-resume+500ms"), 500).unref?.();
}
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

@ -27,8 +27,8 @@ export function getDefaultPetInitialPosition(size: WindowSize = defaultPetWindow
}
export function clampToVisibleWorkArea(position: Point, size: WindowSize = defaultPetWindowSize): Point {
// Clamp to the display the familiar currently lives on (the one nearest its centre),
// not the primary display — otherwise a familiar on an external monitor gets yanked
// Clamp to the display the pet currently lives on (the one nearest its centre),
// not the primary display — otherwise a pet on an external monitor gets yanked
// back to the built-in screen whenever its position is read, saved, or restored.
const centre = { x: position.x + size.width / 2, y: position.y + size.height / 2 };
const { workArea } = screen.getDisplayNearestPoint(centre);

View file

@ -1,232 +0,0 @@
import { createWriteStream } from "node:fs";
import { 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 { maxCodexPetJsonBytes, maxCodexSpritesheetBytes } from "./codex-familiars-core.js";
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
export const maxZipDownloadBytes = 50 * 1024 * 1024;
const maxExtractedTotalBytes = 200 * 1024 * 1024;
const maxFiles = 500;
const maxIndividualFileBytes = 100 * 1024 * 1024;
const downloadTimeoutMs = 30_000;
export 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);
}
}
export 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.familiaros.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/familiars/")) 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);
}
export function validateZipMagic(buffer: Buffer): void {
if (!hasSupportedZipMagic(buffer)) {
throw new Error("Downloaded file has an unsupported zip signature.");
}
}
export 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 (safePath.relativeOutputPath === "familiar.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("familiar.json is too large.");
if (safePath.relativeOutputPath === "spritesheet.webp" && entry.uncompressedSize > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
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);
const maxEntryBytes = safePath.relativeOutputPath === "familiar.json"
? maxCodexPetJsonBytes
: safePath.relativeOutputPath === "spritesheet.webp"
? maxCodexSpritesheetBytes
: maxIndividualFileBytes;
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, maxEntryBytes);
};
zipFile.readEntry();
});
} finally {
zipFile.close();
}
if (!seenRequired.has("familiar.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain familiar.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, maxBytes: 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 > maxBytes) {
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);
});
});
}

View file

@ -1,221 +0,0 @@
import { constants } from "node:fs";
import { lstat, mkdtemp, mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve, sep } from "node:path";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type FamiliarOSStateV1 } from "./app-state.js";
import { getCatalogPet } from "./catalog.js";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-familiars-core.js";
import { builtInPet } from "./built-in-familiar.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./familiar-paths.js";
import { downloadPetZip, extractPetZip, maxZipDownloadBytes, validateZipMagic } from "./familiar-installation-archive.js";
import { assertOutputPathInside } from "./zip-safety.js";
const operations = new Set<string>();
export async function installPet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
throw new Error(`Familiar 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);
const metadata = await validateExtractedPet(tempDir);
if (metadata.id !== petId || metadata.id !== catalogPet.id) {
throw new Error("Catalog familiar package id does not match the requested familiar.");
}
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 installPetFromZipFile(zipPath: string): Promise<FamiliarOSStateV1> {
return withPetOperation("local-import", async () => {
const zip = await readRegularFile(zipPath, maxZipDownloadBytes, "familiar zip");
validateZipMagic(zip);
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const tempDir = await mkdtemp(join(petsRoot, ".local-import-"));
try {
assertInsideRoot(petsRoot, tempDir);
await extractPetZip(zip, tempDir);
const metadata = await validateExtractedPet(tempDir);
await finalizeLocalPetInstall(metadata, tempDir);
return installLocalPetState(metadata);
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function installPetFromFolder(folderPath: string): Promise<FamiliarOSStateV1> {
return withPetOperation("local-import", async () => {
const sourceDir = resolve(folderPath);
const sourceStats = await lstat(sourceDir);
if (sourceStats.isSymbolicLink()) throw new Error("Familiar folder cannot be a symlink.");
if (!sourceStats.isDirectory()) throw new Error("Familiar folder must be a directory.");
if (await realpath(sourceDir) !== sourceDir) throw new Error("Familiar folder path is not canonical.");
const parsed = JSON.parse((await readRegularFile(join(sourceDir, "familiar.json"), maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(sourceDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === metadata.id)) throw new Error(`Familiar is already installed: ${metadata.id}`);
const spritesheet = await readRegularFile(join(sourceDir, metadata.spritesheetPath), maxCodexSpritesheetBytes, "spritesheet.webp");
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const tempDir = await mkdtemp(join(petsRoot, `.local-import-${metadata.id}-`));
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "familiar.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await finalizeLocalPetInstall(metadata, tempDir);
return installLocalPetState(metadata);
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function removePet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
if (petId === builtInPet.id) {
throw new Error("Built-in familiar 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(`Familiar was removed from FamiliarOS 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<FamiliarOSStateV1> {
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 familiar is already in progress.");
}
operations.add(key);
try {
return await callback();
} finally {
operations.delete(key);
}
}
async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata> {
const petJsonPath = join(tempDir, "familiar.json");
const spritesheetPath = join(tempDir, "spritesheet.webp");
assertOutputPathInside(tempDir, petJsonPath);
assertOutputPathInside(tempDir, spritesheetPath);
const parsed = JSON.parse((await readRegularFile(petJsonPath, maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(tempDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
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 > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
return metadata;
}
async function finalizeLocalPetInstall(metadata: CodexPetMetadata, tempDir: string): Promise<void> {
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === metadata.id)) throw new Error(`Familiar is already installed: ${metadata.id}`);
const petsRoot = getPetsRoot();
const finalDir = getInstalledPetDir(metadata.id);
assertInsideRoot(petsRoot, finalDir);
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "familiar.json"));
} catch (error) {
await rm(finalDir, { recursive: true, force: true });
throw error;
}
}
async function readRegularFile(path: string, maxBytes: number, label: string): Promise<Buffer> {
const resolved = resolve(path);
const stats = await lstat(resolved);
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(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const openedStats = await file.stat();
if (!openedStats.isFile() || 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 resolved = resolve(path);
const root = getPetsRoot();
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error("Installed familiar file escapes familiars root.");
const stats = await lstat(resolved);
if (stats.isSymbolicLink()) throw new Error("Imported familiar file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported familiar file must be a regular file.");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function installLocalPetState(metadata: CodexPetMetadata): Promise<FamiliarOSStateV1> {
try {
return installPetState({ id: metadata.id, displayName: metadata.displayName, description: metadata.description });
} catch (error) {
await rm(getInstalledPetDir(metadata.id), { recursive: true, force: true });
throw error;
}
}

View file

@ -1,192 +0,0 @@
import { app } from "electron";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js";
import { builtInPet } from "./built-in-familiar.js";
import { getInstalledPetDir } from "./familiar-paths.js";
import { getActiveLocale, getActiveLocaleLang } from "./i18n/index.js";
import {
createBubbleMarkup,
createPetBodyMarkup,
createPetWindowCss,
createPinnedBubbleMarkup,
createSpriteStateCss,
escapeCssUrl,
escapeHtml,
type PetPluginBubbles,
type PetStatusBadgeReaction,
type PetTransientDisplay,
} from "./familiar-window-render.js";
import { defaultPetSprite, resolveReactionSpriteState, type UniversalSpriteState } from "./reaction-animation-mapping.js";
export interface PetContentRender {
readonly html: string;
readonly bodyHtml: string;
readonly reactionState: UniversalSpriteState;
readonly cacheKey: string;
}
export function getReactionSpriteState(reaction: PetTransientDisplay["reaction"]): UniversalSpriteState {
return resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
}
export async function createDefaultPetRender(
paused: boolean,
display: PetTransientDisplay | null,
badge: PetStatusBadgeReaction | null,
dismissToken?: string,
pluginBubbles: PetPluginBubbles | null = null,
): Promise<PetContentRender> {
const installedPetRender = await tryCreateInstalledPetRender(paused, display, badge, dismissToken, pluginBubbles);
if (installedPetRender) {
return installedPetRender;
}
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
const hasPinned = Boolean(pluginBubbles?.pinned);
const bodyHtml = createPetBodyMarkup("FamiliarOS default familiar", createBubbleMarkup(display, paused, badge, dismissToken, pluginBubbles), `<div class="sprite" role="img" aria-label="Claude animated default familiar"></div>`, createPinnedBubbleMarkup(pluginBubbles), hasPinned);
const reactionState = getReactionSpriteState(display?.reaction);
const stateRows = defaultPetSprite.states;
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
return {
cacheKey: `default:builtin:${paused}:${scale}:${getActiveLocale()}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="${getActiveLocaleLang()}" data-reaction-state="${reactionState}" 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>FamiliarOS Default Familiar</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: familiar-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 familiar-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>
${bodyHtml}
</body>
</html>`,
};
}
export async function tryCreateInstalledPetRender(
paused: boolean,
display: PetTransientDisplay | null,
badge: PetStatusBadgeReaction | null,
dismissToken?: string,
pluginBubbles: PetPluginBubbles | null = null,
): Promise<PetContentRender | null> {
const state = getAppStateSnapshot();
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
if (!selected || selected.id === builtInPet.id || selected.broken) {
return null;
}
try {
return await createInstalledPetRender(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge, `default:${selected.id}`, dismissToken, pluginBubbles);
} catch (error) {
console.error(`Failed to render installed default familiar ${selected.id}; falling back to built-in familiar.`, error);
try {
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed familiar rendering failed.");
} catch (markError) {
console.error(`Failed to mark installed familiar ${selected.id} broken.`, markError);
}
return null;
}
}
export async function createInstalledPetRender(
petId: string,
displayName: string,
paused: boolean,
display: PetTransientDisplay | null,
scale: PetScaleValue,
badge: PetStatusBadgeReaction | null,
cachePrefix: string,
dismissToken?: string,
pluginBubbles: PetPluginBubbles | null = null,
): Promise<PetContentRender> {
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 familiar spritesheet is missing or too large.");
}
const imageUrl = pathToFileURL(spritesheetPath).toString();
const hasPinned = Boolean(pluginBubbles?.pinned);
const bodyHtml = createPetBodyMarkup(escapeHtml(displayName), createBubbleMarkup(display, paused, badge, dismissToken, pluginBubbles), `<div class="installed-card" role="img" aria-label="${escapeHtml(displayName)}"><div class="installed-sprite"></div></div>`, createPinnedBubbleMarkup(pluginBubbles), hasPinned);
const reactionState = getReactionSpriteState(display?.reaction);
const stateRows = defaultPetSprite.states;
return {
cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}:${getActiveLocale()}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="${getActiveLocaleLang()}" data-reaction-state="${reactionState}" 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>FamiliarOS Default Familiar</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: familiar-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 familiar-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>
${bodyHtml}
</body>
</html>`,
};
}

View file

@ -1,144 +0,0 @@
import { app, BrowserWindow } from "electron";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { defaultPetWindowSize, type Point } from "./display.js";
import { debug, error as logError, warn } from "./logger.js";
function resolvePreloadPath(name: string): string {
if (app.isPackaged) {
return join(process.resourcesPath, "app.asar.unpacked", name);
}
return join(dirname(fileURLToPath(import.meta.url)), "..", name);
}
const windowLoadChains = new WeakMap<BrowserWindow, Promise<void>>();
const windowLoadSequences = new WeakMap<BrowserWindow, number>();
export 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: resolvePreloadPath("familiar-preload.cjs"),
},
});
window.setMenu(null);
applyPetAlwaysOnTop(window);
window.on("show", () => applyPetAlwaysOnTop(window));
window.on("restore", () => applyPetAlwaysOnTop(window));
if (process.platform === "darwin") {
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
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) => {
logError("familiar.window", "renderer load failed", { windowId: window.id, errorCode, errorDescription });
console.error("Failed to load default familiar window.", { errorCode, errorDescription });
});
window.webContents.on("console-message", (_event, level, message, line, sourceId) => {
const fields = { windowId: window.id, level, line, sourceId, message };
if (level >= 3) logError("familiar.window", "renderer console", fields);
else if (level === 2) warn("familiar.window", "renderer console", fields);
else debug("familiar.window", "renderer console", fields);
});
window.webContents.on("render-process-gone", (_event, details) => {
logError("familiar.window", "renderer process gone", { windowId: window.id, details });
console.error("Default familiar renderer process gone.", details);
});
return window;
}
function applyPetAlwaysOnTop(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, process.platform === "linux" ? "screen-saver" : "floating");
if (process.platform === "linux") {
window.setVisibleOnAllWorkspaces(true);
}
}
export function isAllowedPetDocumentUrl(url: string): boolean {
return url.startsWith("data:text/html") || url.startsWith("file://");
}
export function allocateWindowLoadSequence(window: BrowserWindow): number {
const sequence = (windowLoadSequences.get(window) ?? 0) + 1;
windowLoadSequences.set(window, sequence);
return sequence;
}
export async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string, sequence: number): Promise<void> {
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "familiar";
const previous = windowLoadChains.get(window) ?? Promise.resolve();
const next = previous.catch(() => {}).then(async () => {
if (window.isDestroyed()) {
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed" });
return;
}
if (windowLoadSequences.get(window) !== sequence) {
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, latestSequence: windowLoadSequences.get(window), reason: "superseded" });
return;
}
const dir = join(app.getPath("userData"), "rendered-familiars");
await mkdir(dir, { recursive: true });
const filePath = join(dir, `${safeName}.html`);
await writeFile(filePath, html, "utf8");
if (window.isDestroyed()) {
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed-after-write" });
return;
}
debug("familiar.window", "load file begin", { windowId: window.id, name: safeName, sequence, filePath });
window.setIgnoreMouseEvents(false);
try {
await window.loadFile(filePath);
debug("familiar.window", "load file complete", { windowId: window.id, name: safeName, sequence, url: window.webContents.getURL() });
} catch (error) {
if (!window.isDestroyed()) {
if (process.platform === "linux") window.setIgnoreMouseEvents(false);
else window.setIgnoreMouseEvents(true, { forward: true });
}
logError("familiar.window", "load file rejected", error instanceof Error ? error : { windowId: window.id, name: safeName, sequence, error });
throw error;
}
});
windowLoadChains.set(window, next);
void next.catch(() => {}).finally(() => {
if (windowLoadChains.get(window) === next) windowLoadChains.delete(window);
});
return next;
}

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