Compare commits

...
Sign in to create a new pull request.

155 commits

Author SHA1 Message Date
OpenPets Dev
f1d1007ffa ci: trigger familiaros website deployment
Some checks failed
Deploy FamiliarOS Website / deploy (push) Has been cancelled
2026-06-21 01:13:48 +00:00
OpenPets Dev
80b968465f fix(familiaros-website): align spinner, metal chrome, and primary button palette
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-21 00:52:48 +00:00
OpenPets Dev
b3d915c00e fix: restore light-mode transparency and visible navbar frame
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-21 00:18:12 +00:00
OpenPets Dev
ad35e7d619 fix: improve public metal button contrast and keep transparent fill
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-21 00:16:28 +00:00
OpenPets Dev
db1e506128 fix: make familiaros marketing shell theme transparent in light and dark
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-21 00:14:21 +00:00
OpenPets Dev
17179cc4f2 docs(website): note marketing-shell pages in silver design language
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 05:39:46 +00:00
OpenPets Dev
55d9f0cd35 chore(website): move public page accent tokens from ivory/gold to silver
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 05:39:34 +00:00
OpenPets Dev
c707b90923 chore(website): place wordmark between emblem and nav in marketing header
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 05:39:06 +00:00
OpenPets Dev
c0455c9746 docs(website): add marketing design language summary for v2026-06-20
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 05:38:54 +00:00
OpenPets Dev
a49b0111fc chore(website): align marketing header and silver visual language
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 05:38:42 +00:00
OpenPets Dev
e0e3439b9e chore: finalize website branding frame and silver CTA
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 04:53:19 +00:00
OpenPets Dev
797c793c36 feat: use emblem icon with wordmark image in marketing header
Some checks are pending
Deploy FamiliarOS Website / deploy (push) Waiting to run
2026-06-20 04:13:41 +00:00
OpenPets Dev
7eacd4dc9c chore: align familiaros marketing header and silver CTA styling 2026-06-20 04:10:38 +00:00
OpenPets Dev
932fed5357 feat(web): apply FamiliarOS brand tokens across website pages 2026-06-20 04:03:14 +00:00
OpenPets Dev
c42e1abe6f Extract plugin familiar action seam 2026-06-19 22:16:30 +00:00
OpenPets Dev
b913978a3c Extract Claude MCP action seam 2026-06-19 22:05:55 +00:00
OpenPets Dev
116d312cca Split agent setup editor helpers by editor 2026-06-19 21:59:38 +00:00
OpenPets Dev
a42efdeea9 Extract default familiar external action seam 2026-06-19 21:50:39 +00:00
OpenPets Dev
ffb58e6712 Extract prompt window script render and layout seams 2026-06-19 21:40:47 +00:00
OpenPets Dev
fa21d29fcd Extract local IPC request handler seam 2026-06-19 21:32:28 +00:00
OpenPets Dev
ef4d4bcd92 Extract prompt window bounds and IPC seams 2026-06-19 21:24:17 +00:00
OpenPets Dev
9c6677564c Extract OpenAPI provider routing and payload seams 2026-06-19 21:15:17 +00:00
OpenPets Dev
8ff7ae1625 Extract catalog remote loading seam 2026-06-19 21:05:28 +00:00
OpenPets Dev
161ec56bf0 Extract plugin manifest shape validation seam 2026-06-19 20:57:15 +00:00
OpenPets Dev
70336c6e30 Extract agent setup Claude status seam 2026-06-19 20:49:00 +00:00
OpenPets Dev
0fd70c2faf Extract plugin JS host SDK dispatch seam 2026-06-19 20:41:23 +00:00
OpenPets Dev
5ab4c5cd0f Extract mouse interop runtime lifecycle seam 2026-06-19 20:24:40 +00:00
OpenPets Dev
19a4a8988c Extract plugin service bundled lifecycle seam 2026-06-19 20:17:19 +00:00
OpenPets Dev
111d0d9fef Extract control center state validation seam 2026-06-19 20:03:42 +00:00
OpenPets Dev
764ba3e962 Extract plugin sdk files namespace seam 2026-06-19 19:51:37 +00:00
OpenPets Dev
871f426224 docs: add overnight mode continuity doctrine 2026-06-19 19:51:26 +00:00
OpenPets Dev
6a372c55e8 Extract default familiar display support seam 2026-06-19 08:03:15 +00:00
OpenPets Dev
655a7b2da1 Extract app state normalization seam 2026-06-19 07:56:56 +00:00
OpenPets Dev
4949f5f2e8 Extract integrations agent sections seam 2026-06-19 07:50:37 +00:00
OpenPets Dev
af8521801f Extract plugin config list editor seam 2026-06-19 07:39:30 +00:00
OpenPets Dev
df0f98a1b7 Extract integrations detail dialog seam 2026-06-19 07:32:16 +00:00
OpenPets Dev
a714b2accc Extract familiar detail dialog seam 2026-06-19 07:23:45 +00:00
OpenPets Dev
a5a37a48f2 Extract OpenAPI provider settings seam 2026-06-19 07:14:54 +00:00
OpenPets Dev
f44a0967e9 Extract default familiar bubble runtime seam 2026-06-19 07:05:57 +00:00
OpenPets Dev
e8b344b6a5 Extract plugin SDK platform namespaces 2026-06-19 07:00:09 +00:00
OpenPets Dev
172c95d7f2 Extract OpenAPI chat plain prompt seam 2026-06-19 06:52:42 +00:00
OpenPets Dev
6618e0021c Extract familiar window mouse interop runtime seam 2026-06-19 06:45:59 +00:00
OpenPets Dev
430ccdb661 Extract default familiar window lifecycle seam 2026-06-19 06:34:16 +00:00
OpenPets Dev
5caf9b1c39 Extract MCP toolkit installer support seams 2026-06-19 06:23:38 +00:00
OpenPets Dev
13e23c60c9 Extract plugin SDK bubble support 2026-06-19 06:16:41 +00:00
OpenPets Dev
2b1da0c5ea Extract knowledge store core helpers 2026-06-19 06:08:33 +00:00
OpenPets Dev
9e75151e56 Extract familiar window mouse interop seam 2026-06-19 05:58:30 +00:00
OpenPets Dev
29bf11afae Extract familiaros memory store seam 2026-06-19 05:40:27 +00:00
OpenPets Dev
1d19ae9ca1 Extract openapi chat prompt flows 2026-06-19 05:34:35 +00:00
OpenPets Dev
394ba13f82 Extract plugins view route seams 2026-06-19 05:22:41 +00:00
OpenPets Dev
794430ae1b Extract default familiar display seam 2026-06-19 05:05:56 +00:00
OpenPets Dev
cb591aca0f Extract familiar installation archive seam 2026-06-19 04:59:23 +00:00
OpenPets Dev
2b1a4e80e2 Extract familiar window host seam 2026-06-19 04:52:24 +00:00
OpenPets Dev
f8575ea012 Extract default familiar motion seam 2026-06-19 04:44:55 +00:00
OpenPets Dev
d5c907016f Extract agent setup tooling seam 2026-06-19 04:40:37 +00:00
OpenPets Dev
6bb7d4ddf7 Extract local IPC socket seam 2026-06-19 03:09:11 +00:00
OpenPets Dev
91e22b216d Extract familiar window bubble seam 2026-06-19 03:03:22 +00:00
OpenPets Dev
99a2de2885 Extract shared UI control-center seams 2026-06-19 02:54:29 +00:00
OpenPets Dev
bb5aa4fc9e Extract integrations toolkit panel seam 2026-06-19 02:38:36 +00:00
OpenPets Dev
d7a5306317 Extract TTS settings row seams 2026-06-19 02:29:47 +00:00
OpenPets Dev
7cc2a1976e Split settings view state action seams 2026-06-19 02:23:30 +00:00
OpenPets Dev
1c33c1022c Extract plugin manifest validation seam 2026-06-19 02:06:50 +00:00
OpenPets Dev
1255770ad9 Extract plugin SDK API builder seams 2026-06-19 01:59:44 +00:00
OpenPets Dev
354199655f Extract plugin service action seams 2026-06-19 01:45:24 +00:00
OpenPets Dev
5064fdecf7 Extract familiars view route seams 2026-06-19 01:38:01 +00:00
OpenPets Dev
0c890b1f8f Extract integrations view state seam 2026-06-19 01:20:39 +00:00
OpenPets Dev
2d8b830dec Split MCP toolkit catalog tiers 2026-06-19 01:14:33 +00:00
OpenPets Dev
d15ccabefa Extract agent setup action seam 2026-06-19 00:54:26 +00:00
OpenPets Dev
50686268c1 Extract plugin service app seams 2026-06-19 00:42:46 +00:00
OpenPets Dev
1f65538f75 Extract openapi chat support seams 2026-06-19 00:36:57 +00:00
OpenPets Dev
ec49296516 Extract settings view state seam 2026-06-19 00:28:05 +00:00
OpenPets Dev
2ab4f61175 Extract plugin sdk command validation seam 2026-06-19 00:10:25 +00:00
OpenPets Dev
80d901a917 Extract familiaros memory search seam 2026-06-19 00:04:39 +00:00
OpenPets Dev
6057065ee7 Extract plugin service local loading seam 2026-06-18 23:54:40 +00:00
OpenPets Dev
9b6b7ad310 Extract familiar window content seam 2026-06-18 21:30:14 +00:00
OpenPets Dev
f272ec678a Split prompt window render script sections 2026-06-18 21:23:06 +00:00
OpenPets Dev
db535f6c98 Extract app state preference and analytics seams 2026-06-18 21:09:50 +00:00
OpenPets Dev
9790f240ff Extract plugin sdk bubble validation seam 2026-06-18 21:00:21 +00:00
OpenPets Dev
8993f477c5 Extract openapi chat tool loop seam 2026-06-18 20:53:27 +00:00
OpenPets Dev
74c8feda39 Extract plugins view config helper seams 2026-06-18 20:45:00 +00:00
OpenPets Dev
922481b18d Extract plugin service dialog support seam 2026-06-18 20:29:57 +00:00
OpenPets Dev
1496a2ebf4 Extract familiar window renderer bridge 2026-06-18 20:22:25 +00:00
OpenPets Dev
35df50ed8a Extract plugin service support barrels 2026-06-18 20:15:03 +00:00
OpenPets Dev
eb075267b1 Extract plugin service catalog support seam 2026-06-18 20:05:48 +00:00
OpenPets Dev
c37065e983 Extract OpenAPI chat config store seam 2026-06-18 19:58:36 +00:00
OpenPets Dev
7df8b3e72b Extract app state familiar record helpers 2026-06-18 19:50:13 +00:00
OpenPets Dev
f0fd49ef7a Extract plugin sdk bridge runtime state helpers 2026-06-18 19:45:41 +00:00
OpenPets Dev
54ba75b74c Extract agent setup command runner 2026-06-18 19:38:48 +00:00
OpenPets Dev
3eded9d264 Extract plugin sdk storage stores 2026-06-18 19:32:32 +00:00
OpenPets Dev
adc5f6ca1b Extract familiar window layout helpers 2026-06-18 19:28:02 +00:00
OpenPets Dev
add3654fce Extract app state storage helpers 2026-06-18 19:20:50 +00:00
OpenPets Dev
dc78637d42 Extract prompt window render script builder 2026-06-18 19:14:05 +00:00
OpenPets Dev
4504319d5f Extract OpenAPI chat request helpers 2026-06-18 19:03:51 +00:00
OpenPets Dev
af3bd09496 Extract plugin sdk runtime helpers 2026-06-18 18:58:01 +00:00
OpenPets Dev
385d14184f Extract agent setup support helpers 2026-06-18 18:53:30 +00:00
OpenPets Dev
8edbecc8ca Split control center IPC route modules 2026-06-18 11:49:16 +00:00
OpenPets Dev
e43cfe01f5 Split settings view action seams 2026-06-18 11:38:38 +00:00
OpenPets Dev
6629c3e3e8 Extract agent setup command context 2026-06-18 11:32:19 +00:00
OpenPets Dev
06fc5f74f5 Reduce plugin service tracked fan-out 2026-06-18 11:25:46 +00:00
OpenPets Dev
da30b28551 Split plugin service helper seams 2026-06-18 11:23:00 +00:00
OpenPets Dev
34c409ee1f Extract familiar window plugin menu helpers 2026-06-18 11:05:19 +00:00
OpenPets Dev
bf546d121c Extract OpenAPI chat conversation store 2026-06-18 10:57:20 +00:00
OpenPets Dev
aa6e78d984 Extract integrations tooling sections 2026-06-18 10:46:22 +00:00
OpenPets Dev
2b1a01c9f9 Extract agent setup editor tools 2026-06-18 10:31:44 +00:00
OpenPets Dev
fd1dc932ad Split control center shared renderer modules 2026-06-18 10:24:27 +00:00
OpenPets Dev
8a6b2a4904 Split settings helper action lanes 2026-06-18 10:10:21 +00:00
OpenPets Dev
7758efad69 Split plugin settings tab sections 2026-06-18 10:02:20 +00:00
OpenPets Dev
1ff1fec94e Extract OpenAPI chat provider helpers 2026-06-18 09:51:29 +00:00
OpenPets Dev
142c4f4412 Refactor prompt window renderer seam 2026-06-18 09:40:16 +00:00
OpenPets Dev
dbb6407774 Extract familiar window interaction helpers 2026-06-18 09:22:20 +00:00
OpenPets Dev
0166745ab2 Refactor Control Center settings view seams 2026-06-18 08:29:12 +00:00
OpenPets Dev
26aba534fe refactor familiar window render helpers 2026-06-18 07:09:18 +00:00
OpenPets Dev
846de24fb0 refactor desktop route loaders and plugin sdk boundaries 2026-06-18 06:40:55 +00:00
OpenPets Dev
d89171c4be refactor desktop runtime control center seams 2026-06-18 05:53:10 +00:00
OpenPets Dev
1d7d2e2c6a Split Control Center routes into modules 2026-06-18 05:08:04 +00:00
OpenPets Dev
a03c3df410 Adopt FamiliarOS GitNexus refactor lane 2026-06-18 04:21:57 +00:00
OpenPets Dev
edfad055a0 Consolidate FamiliarOS workflows and harden catalog surfaces 2026-06-18 02:12:31 +00:00
OpenPets Dev
bb915ddc55 desktop: harden bridge contracts and test coverage 2026-06-18 01:18:08 +00:00
OpenPets Dev
7290d6c26a feat: consolidate FamiliarOS docs and harden public site routes 2026-06-18 00:24:52 +00:00
OpenPets Dev
32f48f571f Update rebrand progress tracker 2026-06-17 22:17:13 +00:00
OpenPets Dev
64ab80c7a7 Wire status-spin animation into thinking and waiting Familiar badges 2026-06-17 22:17:03 +00:00
OpenPets Dev
6d14da2984 docs(design-language): add auth/asset findings and mascot design direction
- Save initial auth/asset reuse findings under
  docs/Design Language and Marketing/FAMILIAROS_AUTH_AND_ASSET_REUSE_FINDINGS.md.
- Save mascot design direction under
  docs/Design Language and Marketing/FAMILIAROS_MASCOT_DESIGN_DIRECTION.md.
- Split into two documents because the topics differ (technical planning vs.
  brand/visual direction).
2026-06-17 21:27:07 +00:00
OpenPets Dev
e2577b05c5 docs(ops): update deployment guidance for monorepo layout
- Refresh DEPLOYMENT_RUNBOOK.md to point to root deploy-website.yml workflow.
- Add FAMILIAROS_SERVER_INSTANCE_GUIDANCE.md under website/docs/ops with
  updated paths for /home/dev/src/FamiliarOS/website.
- Mirror updated guidance to /home/dev/familiaros-deploy/SERVER_INSTANCE_GUIDANCE.md.
2026-06-17 20:59:35 +00:00
OpenPets Dev
63449efbfe Update rebrand progress tracker 2026-06-17 20:56:51 +00:00
OpenPets Dev
07b8959afc Extend loading spinners to TTS settings and MCP preview; add thinking badge hook 2026-06-17 20:56:38 +00:00
OpenPets Dev
1e12a9a54b Integrate FamiliarOS website into product repo under website/
- Add FamiliarOS website subtree under website/
- Move deploy workflow to root .github/workflows/deploy-website.yml
- Update deploy script and runbook paths to /home/dev/src/FamiliarOS/website
- Add website/docs exception to root .gitignore
- Remaining: update familiaros-auth-server.service WorkingDirectory/EnvironmentFile and restart
2026-06-17 20:50:45 +00:00
OpenPets Dev
65ac83849e Add 'website/' from commit '966aea480ffe1c340553aa878def30131d2af827'
git-subtree-dir: website
git-subtree-mainline: 8d3849caea
git-subtree-split: 966aea480f
2026-06-17 20:48:39 +00:00
FamiliarOS Builder
966aea480f FamiliarOS website rebrand, spinners demo, SuperTokens backend, tests green
- Rebrand legal pages and remove Scriptorium identifiers
- Add /spinners demo ported from expo-agent-spinners
- Add self-hosted SuperTokens auth backend under server/
- Install jsdom and fix all failing tests (220 files, 2393 tests green)
- Add deployment runbook and GitHub Actions workflow
- Document GitHub OAuth disabled-by-default policy
2026-06-17 20:48:24 +00:00
OpenPets Dev
8d3849caea docs: add FamiliarOS rebrand progress tracker 2026-06-17 20:27:23 +00:00
FamiliarOS Builder
48471ba2bc Rebrand ChangelogPage with FamiliarOS release history 2026-06-17 06:43:07 +00:00
FamiliarOS Builder
fae8a6d403 Rebrand AffiliateProgram and AffiliateResources pages for FamiliarOS 2026-06-17 06:41:18 +00:00
FamiliarOS Builder
92a85cf77a Rebrand auth pages for GitHub OAuth, remove academic verification artifacts 2026-06-17 06:39:03 +00:00
FamiliarOS Builder
cb17a7aad8 Rewrite HelpCenterPage with FamiliarOS user guides 2026-06-17 06:32:28 +00:00
FamiliarOS Builder
a2bf68ae77 Rebrand SupportPage FAQ, contact, and support tracks for FamiliarOS 2026-06-17 06:30:02 +00:00
FamiliarOS Builder
a6a51872d6 Restore LogosSection and SocialProof with believable tech/dev content 2026-06-17 06:28:20 +00:00
OpenPets Dev
4db3beb032 feat(ui): add loading spinner to dashboard and integrations loading states
- Add a reusable Spinner component in the renderer.
- Show it in the dashboard and integrations initial-load placeholders.
- Spinner uses current cyan accent; easy to swap for a custom asset later.
2026-06-17 06:11:10 +00:00
OpenPets Dev
648aac86c2 feat(hardening): virtual-familiar plugin fix, TTS guard, stale placeholder cleanup
- Update familiaros.virtual-familiar manifest id, permissions, and asset key
  to match the rebrand and the v3 SDK (familiar:* permissions).
- Rename plugin asset virtual-pet.svg -> virtual-familiar.svg.
- Migrate plugin code from ctx.pet to ctx.familiar and pet:clicked to
  familiar:clicked so speech/reactions actually work.
- Update plugin locale strings from Pet to Familiar.
- Add empty-text guard in speakTts to avoid speaking whitespace.
- Remove stale "settings.tts.placeholder" key from all locale files.,
2026-06-17 06:08:53 +00:00
FamiliarOS Builder
8c4f5a65f1 Update index metadata, sitemap, and webmanifest to FamiliarOS 2026-06-17 06:06:08 +00:00
FamiliarOS Builder
01e96c5a82 Rebrand DocsPage for FamiliarOS features and surface map 2026-06-17 06:04:51 +00:00
FamiliarOS Builder
aaf4fc5e13 Remove unused ItshoverExternalLinkIcon import 2026-06-17 06:02:43 +00:00
FamiliarOS Builder
1e3fa676d5 Remove dashboard /app links now that only public routes remain 2026-06-17 06:02:10 +00:00
OpenPets Dev
4d99cd32fe feat(hardening): taglines, MCP Toolkit Guide rebrand, Windows bubble fix, name test
- Rename UI panel to "MCP Toolkit Guide" with a plainer description.
- Apply taglines to README.md and package.json descriptions.
- Gate pinned-bubble backdrop-filter on Windows to avoid gray rectangles.
- Add packaging-contract assertions for pinned bubble and new description.
- Add custom-familiar-name.test.ts covering normalizeFamiliarName.
- Sync docs (FEATURES, README_OUR_CHANGES, FEATURES_OUR_CHANGES,
  PULL_REQUEST, FEATURE_REGISTRY, bug-squashing, rebrand plan,
  pr-chat-memory-mcp-toolkit).
2026-06-17 06:01:32 +00:00
FamiliarOS Builder
4d29efb89b Rebrand AboutPage for FamiliarOS companion platform 2026-06-17 06:01:26 +00:00
FamiliarOS Builder
39e282639b Rebrand PricingPage to Free/Companion/Agent tiers with FamiliarOS features 2026-06-17 05:59:10 +00:00
FamiliarOS Builder
5f2fcea5bf Rebrand HomeLanding with FamiliarOS content and remove unused Scriptorium marketing sections 2026-06-17 05:56:50 +00:00
FamiliarOS Builder
726fc23301 Ignore dist/ build output 2026-06-17 05:53:02 +00:00
FamiliarOS Builder
0cb3bcace1 Rebrand MarketingShell logo, tagline, footer, and social links 2026-06-17 05:52:56 +00:00
FamiliarOS Builder
e263cd62e6 Add .gitignore and remove node_modules from git 2026-06-17 05:49:12 +00:00
FamiliarOS Builder
99867039a2 Phase 0-1: Copy, simplify router to public-only, global ScriptoriumAI→FamiliarOS rebrand 2026-06-17 05:48:39 +00:00
FamiliarOS Builder
f9c74b6176 Initial copy from ScriptoriumAI UI 2026-06-17 05:42:55 +00:00
OpenPets Dev
9bf7ca2210 feat(i18n): lucid English descriptions for the three Integration panels 2026-06-17 01:50:51 +00:00
OpenPets Dev
5d956b7d0e docs: update FEATURE_REGISTRY branch and HEAD 2026-06-17 01:49:43 +00:00
OpenPets Dev
69d3046ad7 docs: update FEATURE_REGISTRY HEAD after rebrand docs commit 2026-06-17 01:49:09 +00:00
OpenPets Dev
a030240467 docs: FamiliarOS marketing copy, rebrand docs, and custom Familiar name
- Rewrite README.md hero and feature sections with FamiliarOS manifest copy.
- Update README_OUR_CHANGES.md, FEATURES.md, FEATURES_OUR_CHANGES.md,
  PULL_REQUEST.md, and FEATURE_REGISTRY.md with rebrand and custom naming.
- Rename packages/install-familiar/src/check-install-pet.ts to
  check-install-familiar.ts to match rebrand and package test script.
- Custom Familiar name already wired through app-state, tray, Settings UI,
  and i18n; docs now describe it.
2026-06-17 01:48:55 +00:00
OpenPets Dev
6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar.
- Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages.
- Rename plugin IDs and directories from openpets.* to familiaros.*.
- Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration.
- Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents.
- Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings.
- Add Familiar naming feature: preference, settings input, tray menu display.
- Update assets and packaging config; all desktop tests pass.
2026-06-17 01:42:08 +00:00
1765 changed files with 672208 additions and 24423 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 OpenPets log. It is not required, but it is highly recommended because it often explains startup, tray, pet display, and integration issues.
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.
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/`
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/`
If the problem happened before the latest restart, `openpets.previous.log` may also be useful.
If the problem happened before the latest restart, `familiaros.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 pet disappeared after..."
placeholder: "The desktop familiar disappeared after..."
validations:
required: true
@ -30,7 +30,7 @@ body:
id: logs
attributes:
label: Logs (optional, highly recommended)
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."
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."
validations:
required: false

View file

@ -1,5 +1,5 @@
name: Feature request
description: Suggest an improvement for OpenPets.
description: Suggest an improvement for FamiliarOS.
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 OpenPets to..."
placeholder: "I want FamiliarOS to..."
validations:
required: true

View file

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

44
.github/workflows/deploy-website.yml vendored Normal file
View file

@ -0,0 +1,44 @@
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,10 +9,13 @@ 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

1
.gitnexus/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*

12
.sentrux/baseline.json Normal file
View file

@ -0,0 +1,12 @@
{
"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
}

78
.sentrux/rules.toml Normal file
View file

@ -0,0 +1,78 @@
[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/pets",
"root": "/Volumes/external/repos/familiars",
"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-pet-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-familiar-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-pet.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/built-in-familiar.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-pets-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-pets.ts": "885f214e14db799953b1ccf13a264478",
"apps/desktop/src/default-pet-controller.ts": "a46002d908de6596947cdaa1d122ea84",
"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/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/pet-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/pet-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/pet-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/familiar-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/familiar-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/familiar-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-pet-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-familiar-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-pet/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-pet/src/check-install-pet.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-pet/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/install-familiar/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-familiar/src/check-install-familiar.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-familiar/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/pet-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/pet-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/familiar-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/familiar-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-pet": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-pet/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/install-familiar": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-familiar/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/mcp": "786e8bc32d478dbaa34bc3d1831cc6ec",
"packages/mcp/src": "e89d476284c4ec2814c11a064fcb210b",
"packages/opencode": "dd2382941a7bfe672b2f27dcbfa51e45",
"packages/opencode/src": "ed024a6207b42efd076da44ab9587adb",
"packages/pet-format": "ca85af1412e98214135e22bdcba5f406",
"packages/pet-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/familiar-format": "ca85af1412e98214135e22bdcba5f406",
"packages/familiar-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 pet-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 familiar-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 `openpets.log`, not only DevTools.
Route renderer diagnostics into the app log when possible so failures are visible in `familiaros.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 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.
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.
## Ubuntu VMware Testing
@ -51,16 +51,138 @@ 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 OpenPets checkout: `/home/vagrant/src/openpets`
- Guest helper aliases: `cdpets` and `openpets-dx`
- Guest FamiliarOS checkout: `/home/vagrant/src/familiaros`
- Guest helper aliases: `cdpets` and `familiaros-dx`
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`.
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`.
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 `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`.
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`.
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.
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`

View file

@ -1,12 +1,12 @@
# OpenPets Control Center: Design System & Engineering Manual
# FamiliarOS Control Center: Design System & Engineering Manual
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.
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.
---
## 1. Visual Direction & Heritage
The OpenPets Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop pet managers with a modern, glassmorphic utility interface.
The FamiliarOS Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop familiar 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 (`openpets.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 (`familiaros.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 pet"* 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 familiar"* 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 OpenPets catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original FamiliarOS 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 pet 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 familiar 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.
### Pet Cards & Badges
### Familiar 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/OpenPets pets
- `Featured` for featured non-original catalog pets
- `Western` / `Asian` for standard category pets
- `Original` for original/FamiliarOS familiars
- `Featured` for featured non-original catalog familiars
- `Western` / `Asian` for standard category familiars
- `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 `.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").
- **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").
### C. Onboarding
- **Old Layout**: Full screen wizard.

View file

@ -1,11 +1,11 @@
# OpenPets Feature Inventory
# FamiliarOS Feature Inventory
This file is the current feature roll-up for this working fork of OpenPets.
This file is the current feature roll-up for this working fork of FamiliarOS.
It combines:
- the shipped baseline described in `README.md`
- the chat, memory, and prompt-window work from this implementation thread
- the curated MCP toolkit surface added in the desktop app
- the MCP Toolkit Guide 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 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
- 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
- Drag support with mouse passthrough recovery for Windows/Linux edge cases
- Adaptive pet scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default pet position
- Adaptive familiar scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default familiar position
- Launch at login support where the platform allows it
- Update checking against GitHub releases
- Open logs folder from the app
## Pet Packs And Gallery
## Familiar Packs And Gallery
- 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
- 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
## Reactions And Speech
- Explicit pet speech bubbles
- Explicit familiar speech bubbles
- Reaction-only decorative bubble messages
- Reaction animation mapping
- User-configurable reaction-to-animation overrides
@ -51,9 +51,9 @@ 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
## Pet Text-to-Speech (Phase 2)
## Familiar Text-to-Speech (Phase 2)
- Settings > **Text-to-Speech** panel controls voice output for the pet and floating chat.
- 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.
@ -61,25 +61,32 @@ It combines:
- 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 pet window renderer via a shared TTS service (`apps/desktop/src/tts-service.ts`).
- 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 pet to speak text through the same TTS pipeline.
- Plugin SDK bridge: plugins granted the `voice:speak` permission can ask the familiar to speak text through the same TTS pipeline.
## Virtual Pet Care
## Virtual Familiar Care
- 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
- 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
- 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 pet to open a floating prompt window
- Double-click the familiar 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
@ -103,9 +110,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 pet bubble
- Pet bubble only shows one assistant reply at a time
- Per-entry styling with role badges (You / Pet / System / Error) and timestamps
- 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
- **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
@ -116,7 +123,7 @@ It combines:
## OpenAPI And BYOK Chat
- Main-process OpenAPI chat service
- User-entered prompt stays out of the pet renderer
- User-entered prompt stays out of the familiar 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
@ -141,12 +148,12 @@ It combines:
- Custom local template
- Generic HTTPS template
## Pet Character And Personality
## Familiar Character And Personality
- System-prompt style pet character field in General Settings
- System-prompt style familiar 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 OpenPets behavior instructions in every chat
- Base instructions toggle — optionally include or exclude the default FamiliarOS behavior instructions in every chat
- Theme setting shared between control center and floating chat window
- Auto theme mode
- Light theme mode
@ -155,7 +162,7 @@ It combines:
## Memory System
- Local-first persistent OpenPets memory store
- Local-first persistent FamiliarOS memory store
- Main-process memory writer
- In-memory retrieval during chat
- On-disk persistence for durable memories
@ -168,43 +175,45 @@ 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 (`openpets-memory.json`) and chat history (`openpets-chat-history.json`)
- Two separate databases: explicit memories (`familiaros-memory.json`) and chat history (`familiaros-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 `openpets-knowledge-store.json`
- 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 OpenPets memories
- 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
## OpenPets MCP Server
## 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.
- Local IPC discovery file and per-run token flow
- 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`
- Central **OpenPets MCP Server** panel in Control Center → Integrations
- 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
- Pet routing selector 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
@ -223,12 +232,14 @@ It combines:
- OpenCode plugin support
- Cursor MCP config preview
- Cursor rules preview
- Pet routing centralized in the OpenPets MCP Server panel
- Published package / bundled / local command modes centralized in the OpenPets MCP Server panel
- 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 OpenPets MCP Server panel
- Node command path centralized in the FamiliarOS MCP Server panel
## MCP Tool Servers (Built-in Chat)
## MCP Tool Servers (Built-in Chat 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
@ -238,9 +249,9 @@ It combines:
- 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
## Curated MCP Toolkit Surface
## MCP Toolkit Guide Surface
The control-center Integrations page now also includes a curated **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.
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.
### Starter Stack In The Toolkit
@ -303,14 +314,14 @@ The control-center Integrations page now also includes a curated **Curated MCP T
## Dashboard And Control Center
- Dashboard overview
- Pet count
- Familiar count
- Plugin health summary
- Update status summary
- Reaction mix charting
- Top companion activity
- Activity counters
- Settings view
- Pets view
- Familiars view
- Plugins view
- Integrations view
- Persistent route handling inside the control center window
@ -323,7 +334,7 @@ The control-center Integrations page now also includes a curated **Curated MCP T
- Verbose dev logging toggle through environment
- IPC request logging
- Lease acquisition logging
- Pet show lifecycle logging
- Familiar show lifecycle logging
- Renderer console logging capture
## Packaging And Distribution
@ -332,15 +343,15 @@ The control-center Integrations page now also includes a curated **Curated MCP T
- Windows packaging support
- Linux AppImage packaging support
- Additional Linux targets in config
- Packaged preload scripts for control center, pet window, prompt window, and plugin surfaces
- Packaged preload scripts for control center, familiar 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 pet renderer
- Prompt text is kept out of the familiar renderer
- Automatic agent speech is decorative and best effort
- OpenPets speech bubbles are not meant to carry code, logs, URLs, file paths, or secrets
- FamiliarOS 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
@ -348,6 +359,6 @@ The control-center Integrations page now also includes a curated **Curated MCP T
This fork now gives you three layers at once:
- a playful desktop pet
- a playful desktop familiar
- a floating always-on-top chat surface with BYOK and memory
- a curated MCP/workbench surface for building a serious developer stack around the pet instead of only driving it from a terminal
- a curated MCP/workbench surface for building a serious developer stack around the familiar instead of only driving it from a terminal

View file

@ -1,10 +1,10 @@
# OpenPets — Our Changes (Isolated Feature List)
# FamiliarOS — 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 pet to open a frameless, always-on-top floating prompt window.
- Double-click the familiar 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, Pet, System, Error.
- Per-entry role badges: You, Familiar, 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 (`openpets-chat-history.json`).
- Persists to disk across app restarts (`familiaros-chat-history.json`).
## OpenAPI BYOK Chat
@ -29,15 +29,22 @@ 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`.
## Pet Character & Base Instructions
## Familiar Character & Base Instructions
- User-editable system-prompt style pet character field in General Settings.
- User-editable system-prompt style familiar 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 OpenPets behavior instructions in every chat.
- Base instructions toggle to include/exclude the default FamiliarOS behavior instructions in every chat.
## Pet Text-to-Speech (Phase 2)
## Custom Familiar Name
- Settings > **Text-to-Speech** panel for the pet and floating chat.
- 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.
@ -50,7 +57,7 @@ This document lists only the features, UI surfaces, and capabilities added by ou
## Memory System
- Local-first durable OpenPets memory store.
- Local-first durable FamiliarOS 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.
@ -59,7 +66,7 @@ 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 (`openpets-memory.json`) and chat history (`openpets-chat-history.json`).
- Separate databases for explicit memories (`familiaros-memory.json`) and chat history (`familiaros-chat-history.json`).
## MCP Tool Servers (Built-in Chat)
@ -71,26 +78,22 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- 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.
## OpenPets MCP Server (External-Agent Bridge)
## FamiliarOS MCP Server (External-Agent Bridge)
- Central **OpenPets MCP Server** panel in Control Center → Integrations.
- 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.
- Pet routing selector 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.
## Curated MCP Toolkit Integration
## MCP Toolkit Guide Integration
- Curated MCP Toolkit panel inside Control Center → Integrations.
- 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.
- Multi-select chips by tier (Starter, Terminal & Systems, Advanced).
- 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
- Copy-paste snippets and docs links for deliberate adoption.
- Removed redundant Curated Stack section in favor of direct tiered selection.
## Knowledge Store
@ -100,21 +103,21 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- 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 OpenPets memories.
- 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`).
## Pet Scale & Sizing
## Familiar Scale & Sizing
- 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.
- 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.
## Multi-Screen Support
- 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.
- 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.
## Multi-Conversation Chat
@ -124,22 +127,22 @@ 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 `openpets-chat-conversations.json`.
- Conversation metadata stored in `familiaros-chat-conversations.json`.
## Settings & Control Center
- Memory viewer section in Settings.
- MCP Tool Servers panel in Integrations for built-in chat tool activation.
- OpenPets MCP Server panel in Integrations for external-agent bridge configuration.
- FamiliarOS MCP Server panel in Integrations for external-agent bridge configuration.
- OpenAPI chat settings: endpoint, model, credential, theme, base instructions toggle.
- Moonshot/Kimi API endpoint preset.
## Virtual Pet Status HUD
## Virtual Familiar Status HUD
- 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.
- 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.
- Low-stat nudges continue to work in the background.
## Build & Packaging

View file

@ -1,29 +1,34 @@
# OpenPets Feature Registry
# FamiliarOS Feature Registry
This is the canonical feature registry for the VectorShell working fork of OpenPets. 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.
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:** `v3.1.0-integrated`
- **Current HEAD:** `bb758e9`
- **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 / Pet core
## 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 pets | shipped | upstream | — | `apps/desktop/src/pet-installation.ts` | Baseline. |
| Always-on-top pet window | shipped | upstream | — | `apps/desktop/src/pet-window.ts` | Baseline. |
| Drag with mouse passthrough | shipped | `fix/pet-window-stability` | `11ed7b3` | `apps/desktop/src/pet-window.ts`, `apps/desktop/pet-preload.cjs` | Removes post-drag resize jump; anchors resize around pet foot/center. |
| Adaptive pet 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. |
| 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/pet-window.ts` | Handle added, then removed in favor of Settings `+`/`-` nudge buttons. |
| Multi-screen positioning | shipped | main PR | `08eff41` | `apps/desktop/src/pet-window.ts` | Uses `screen.getDisplayNearestPoint()` / `clampToNearestWorkArea()`. |
| Speech-bubble layout | shipped | main PR + OP2 | `08eff41`, `8648cad`, `f514960` | `apps/desktop/src/pet-window.ts` | Larger bubbles, top-of-screen flip, responsive max-width for large pets. |
| Single-click petting | shipped | `pr/single-click-petting` | `46028c7`, refined `be580ba` | `apps/desktop/pet-preload.cjs`, `plugins/official/openpets.virtual-pet/index.js` | Single click emits `pet:clicked`; double click opens chat. **Not a v3.1.0 follow-up fix; pre-existing feature refined by OP1.** |
| Virtual-pet context menu | shipped | `pr/virtual-pet-context-menu` + `refactor/virtual-pet-context-menu` | `98b7875`, `ab62d9c`, `35e4d74` | `plugins/official/openpets.virtual-pet/index.js`, `plugins/official/openpets.virtual-pet/locales/en.json` | HUD moved from triple-click overlay to right-click context menu; care actions reordered/renamed. |
| 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
@ -45,20 +50,20 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| 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. |
| Pet 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 OpenPets behavior instructions. |
| 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/openpets-memory.ts` | JSON store + markdown mirror. |
| Memory capture patterns | shipped | main PR | `08eff41` | `apps/desktop/src/openpets-memory.ts` | `remember`, `my name is`, `I prefer`, etc. |
| Memory kinds + importance + tags | shipped | main PR | `08eff41` | `apps/desktop/src/openpets-memory.ts` | preference, identity, fact, note. |
| 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/openpets-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/openpets-memory.ts` | Cross-conversation excerpts injected into prompt. |
| 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
@ -74,14 +79,14 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
## 6. MCP edition
### 6a. OpenPets MCP server
### 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` | `open-pets` MCP server. |
| 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-pet fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
| Lease routing + default-familiar fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
### 6b. MCP Tool Servers (built-in chat)
@ -93,15 +98,15 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| 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. OpenPets MCP Server (external-agent bridge)
### 6c. FamiliarOS MCP Server (external-agent bridge)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Central "OpenPets 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, pet routing, test server, and MCP JSON for external hosts. |
| 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.openpets` entry. |
| MCP JSON copy | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | One-click copy of the `mcpServers.familiaros` entry. |
### 6d. Curated MCP Toolkit
### 6d. MCP Toolkit Guide
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
@ -124,21 +129,21 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| 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-pet. |
| Virtual-pet plugin bundling | shipped | `pr/virtual-pet-context-menu` | `98b7875` | `plugins/official/openpets.virtual-pet/index.js` | Re-enabled after temporary disable. |
| 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/pet-preload.cjs`, `apps/desktop/prompt-window-preload.cjs` | OS speech engine via Web Speech API; rate 0.5×2.0× and voice-name matching. |
| 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` | `openpets:get-tts-settings`, `openpets:get-tts-voices`, `openpets:save-tts-credential`, `openpets:test-tts`, `openpets:tts-stop`. |
| 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. |
@ -150,7 +155,7 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| 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:\openpets-build\apps\desktop\dist-electron\OpenPets-3.1.0-win-x64-setup.exe` | SHA256: `79b0c01b08d6121f54675c176a5d7c22d1f8e3591b191d760aae6d6300d4b5bb`. |
| 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`. |
---
@ -158,9 +163,9 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| Capability | What happened | Replacement | Commits |
|------------|---------------|-------------|---------|
| On-pet drag-to-resize handle | Removed | Settings scale slider + `+`/`-` nudge buttons + keyboard arrows | `4aa60a3`, `be21907`, `26d446f` |
| Virtual-pet triple-click HUD overlay | Removed | Right-click context menu **Pet Status** | `4aa60a3`, `98b7875` |
| Triple-click pet action | Removed | Single-click petting + double-click chat | `46028c7` |
| 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` |
---
@ -173,11 +178,11 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| `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/OPENPETS_3_1_0_FIXES_PULL_REQUESTS.md` | Categorized fix PRs for v3.1.0. |
| `docs/pull-requests/OPENPETS_3_1_0_MOD_NICHE_PULL_REQUESTS.md` | Same fixes organized by mod niche. |
| `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-pet context-menu 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. |
@ -192,7 +197,7 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
- `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/pet-window-stability`, `fix/prompt-window-hardening`, `fix/prompt-window-signed-build`, `fix/scale-handle-and-keyboard`, `refactor/virtual-pet-context-menu` — topic branches from the six original PRs.
- `op1/pet-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-pet-context-menu` — PR branch pointers.
- `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 OpenPets
Copyright (c) 2026 FamiliarOS
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: Floating Chat, Memory, Knowledge Store, MCP Servers, Curated MCP Toolkit, TTS Phase 2 & Pet Sizing
# Pull Request: FamiliarOS Rebrand, Custom Familiar Naming, Floating Chat, Memory, Knowledge Store, MCP Servers, MCP Toolkit Guide, TTS Phase 2 & Familiar Sizing
**Title:** feat(desktop): add floating chat, local memory, Knowledge Store, MCP Tool Servers, OpenPets MCP Server panel, Curated MCP Toolkit, Text-to-Speech Phase 2, expanded pet sizing, and multi-screen support
**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
**Author:** VectorShell working fork
**Base:** upstream `main` at `cba81b7`
@ -10,47 +10,58 @@
## Summary
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.
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.
## Motivation
- Users wanted to chat with the pet without a terminal or external agent.
- Users wanted to chat with the familiar 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.
- Pet sizing was limited to three presets with no continuous or interactive adjustment.
- Familiar 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 pet.
- Frameless, always-on-top chat UI opened by double-clicking the familiar.
- 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 / Pet / System / Error), color coding, timestamps.
- Persists to disk (`openpets-chat-history.json`).
- Role badges (You / Familiar / System / Error), color coding, timestamps.
- Persists to disk (`familiaros-chat-history.json`).
- **Memory Viewer**
- New Settings section to search, edit, and delete OpenPets memories.
- New Settings section to search, edit, and delete FamiliarOS 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.
- Tiered chip selector (Starter / Terminal & Systems / Advanced).
- **OpenPets MCP Server Panel**
- **FamiliarOS MCP Server Panel**
- New central Integrations card for the external-agent bridge.
- Command mode, node path, and pet routing in one place.
- 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 pet / floating-chat renderer via `openpets:tts-speak`, `openpets:tts-audio`, and `openpets:tts-stop` IPC.
- 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.
@ -58,10 +69,10 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
- `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/openpets-memory.ts` — Local memory engine with JSON store and markdown mirror.
- `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 pet / floating chat with quiet-hours check.
- `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.
@ -69,19 +80,20 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
- `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.
### Pet improvements
### Familiar improvements
- Continuous pet scale slider from 0.16x to 10x in Settings.
- Continuous familiar 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-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.
- 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.
### Settings additions
- OpenAPI chat: endpoint preset, model, credential, theme, base-instructions toggle.
- Moonshot/Kimi endpoint preset.
- Pet character / system prompt field.
- Familiar character / system prompt field.
- **Familiar name** field for the default Familiar.
- Memory viewer with search/edit/delete.
- **Knowledge Store** tab: upload files, search stored knowledge, delete files, and add manual memory entries.
@ -100,12 +112,12 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
### Updated files
- `README.md` — added floating chat, memory, MCP Tool Servers, OpenPets MCP Server, and Curated MCP Toolkit bullets.
- `README.md` — added floating chat, memory, MCP Tool Servers, FamiliarOS MCP Server, and MCP Toolkit Guide bullets.
- `FEATURES.md` — comprehensive inventory of new capabilities.
- `docs/pr-chat-memory-mcp-toolkit.md` — implementation explainer.
- `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.
- `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.
- `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.
@ -122,7 +134,7 @@ 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/openpets-memory.ts
apps/desktop/src/familiaros-memory.ts
apps/desktop/src/prompt-window.ts
apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
apps/desktop/src/knowledge-store-core.ts
@ -139,25 +151,26 @@ apps/desktop/tests/tts-engine.test.ts
```bash
pnpm install
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
pnpm --filter @familiaros/desktop build
pnpm --filter @familiaros/desktop test
```
All existing tests pass. Additional manual verification:
1. Launch the desktop app and double-click the pet — floating chat should open.
1. Launch the desktop app and double-click the familiar — 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 pet bubble and in the history list.
3. Send a message; assistant reply should appear in the familiar 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 > OpenPets MCP Server — choose a command mode, click Test Server, and verify the server responds.
8. Open Integrations > Curated MCP Toolkit to browse copy-paste snippets and optional persistent bundles.
9. Open Settings > Text-to-Speech, choose System voice, click **Speak** — the pet should speak the test phrase.
10. Add an OpenAI TTS key, select a voice, click **Speak** — audio should play through the pet window.
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 pet → choose Virtual Pet ▸ Feed/Play/Pet/Nap; stats update and the context menu reflects the new values.
13. Change display — pet should stay on the display it is currently near.
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.
## Backwards compatibility
@ -176,5 +189,5 @@ All existing tests pass. Additional manual verification:
1. Floating chat + OpenAPI BYOK
2. Memory system + memory viewer
3. Vanilla chat MCP tools
4. Pet sizing expansion + drag handle
4. Familiar sizing expansion + drag handle
5. Multi-screen positioning

431
README.md
View file

@ -1,21 +1,25 @@
<p align="center">
<img src="assets/openpets.png" alt="OpenPets - pixel art desktop companion" width="100%" />
<img src="assets/familiaros.png" alt="FamiliarOS" width="100%" />
</p>
<p align="center">
<strong>A tiny desktop companion that keeps you company.</strong>
<strong>Create your Familiar.</strong>
</p>
<p align="center">
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
Your Familiar remembers, speaks, and acts across your system.
</p>
<p align="center">
<sub>This branch also includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>Knowledge Store</strong>, <strong>MCP Tool Servers</strong>, an <strong>OpenPets MCP Server</strong> panel, a <strong>Curated MCP Toolkit</strong>, <strong>Text-to-Speech (Phase 2)</strong>, and <strong>expanded pet sizing</strong>. See <code>FEATURES.md</code>, <code>FEATURE_REGISTRY.md</code>, and <code>PULL_REQUEST.md</code> for details.</sub>
<sub>A local-first AI companion with memory, voice, knowledge, and tools.</sub>
</p>
<p align="center">
<img src="assets/intro.png" alt="OpenPets reacting across multiple coding agent sessions" width="100%" />
<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%" />
</p>
<div align="center">
@ -27,389 +31,174 @@
</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 [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.
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.
If you also want coding-agent integration, install the OpenPets Agent Skill with [skills.sh](https://skills.sh/):
If you also want coding-agent integration, install the FamiliarOS Agent Skill with [skills.sh](https://skills.sh/):
```bash
npx skills add alvinunreal/openpets --skill openpets
npx skills add verticaltension/familiaros --skill familiaros
```
Then open Claude Code, OpenCode, Codex, or another skill-aware agent and say:
```text
Use the OpenPets skill. Install OpenPets for me, connect this agent, and verify the integration works.
Use the FamiliarOS skill. Install FamiliarOS for me, connect this agent, and verify the integration works.
```
For project setup, open your agent inside the repo and say:
```text
Use the OpenPets skill. Help me choose or install a pet, configure it for this project, and verify the project integration.
Use the FamiliarOS skill. Help me choose or install a Familiar, configure it for this project, and verify the project integration.
```
Useful prompts:
```text
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.
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.
```
## Star OpenPets
---
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.
## 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.
<p align="center">
<img src="assets/star-repo.gif" alt="Starring the OpenPets repository" width="100%" />
<img src="assets/manage-familiars.png" alt="Managing Familiars in the FamiliarOS desktop app" 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 Server 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.
- **Text-to-Speech (Phase 2)** - configurable system, OpenAI, ElevenLabs, Piper, or OpenAI-compatible TTS for the pet and floating chat, with voice/model/speed controls and a test preview.
- **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 OpenPets Desktop
### 1. Install FamiliarOS Desktop
Download the latest app from [OpenPets Releases](https://github.com/alvinunreal/openpets/releases/latest):
Download the latest app from [FamiliarOS Releases](https://github.com/verticaltension/familiaros/releases/latest):
- **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`
- **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`
Launch OpenPets. You should see the desktop pet and the OpenPets tray/menu-bar icon.
Launch FamiliarOS. You should see the desktop Familiar and the FamiliarOS 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/OpenPets.app
open /Applications/OpenPets.app
xattr -dr com.apple.quarantine /Applications/FamiliarOS.app
open /Applications/FamiliarOS.app
```
### 2. Optional: connect your agent
Use the desktop **Integrations** screen for global setup when available:
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.
- **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.
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).
<p align="center">
<img src="assets/integrations.png" alt="OpenPets desktop integrations screen" width="100%" />
</p>
---
For project-local setup, run the CLI from the project you want to configure:
## MCP in plain English
```bash
npx -y @open-pets/cli@latest configure --agent claude --pet <petId>
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
```
FamiliarOS includes three MCP-related surfaces. They are separate on purpose:
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`.
### MCP Tool Servers — tools *for* your Familiar's chat
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.
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.
## Advanced: agent integrations
### FamiliarOS MCP Server — your Familiar as a tool *for* external agents
OpenPets integrations have three layers:
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.
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.
### MCP Toolkit Guide — a safe manual for the wider MCP world
### Claude Code
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 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.
### MCP Tool Servers
The desktop app includes a dedicated **MCP Tool Servers** panel under **Integrations** for the built-in floating chat. Activate tool servers such as filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, and sqlite so the pet can use them directly. This works independently of whether Claude, Cursor, OpenCode, or Pi are installed.
### OpenPets MCP Server
A central **OpenPets MCP Server** panel under **Integrations** configures the external-agent bridge. It collects command mode (published / bundled / local), node path, and pet routing in one place, and provides a **Test Server** check and a **Copy MCP JSON** button for pasting into Claude Code, Cursor, Codex CLI, or any other MCP host.
### Curated MCP toolkit
The desktop app also includes a manual-but-curated **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 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
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
```
### Run the desktop app
See `FEATURES.md` and `FEATURE_REGISTRY.md` for a full capability map.
```bash
pnpm dev:desktop
```
---
Equivalent package command:
## License
```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.
MIT

View file

@ -1,10 +1,10 @@
# OpenPets — Our Additions (Isolated README)
# FamiliarOS — 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 pet to open an always-on-top floating chat window.
Double-click the desktop familiar 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,13 +16,13 @@ Double-click the desktop pet to open an always-on-top floating chat window.
## New: Local Memory
OpenPets now remembers things for you.
FamiliarOS 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 OpenPets MCP tools (`openpets_memory_*`).
- Also exposed through existing FamiliarOS MCP tools (`familiaros_memory_*`).
## New: Knowledge Store
@ -31,67 +31,83 @@ Store files and add manual knowledge entries that the assistant can reference du
- 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 OpenPets memories.
- Combined search across stored files and FamiliarOS memories.
## New: MCP Tool Servers (Built-in Chat)
## New: MCP Tool Servers — Tools for Your Familiar's Chat
Activate MCP tool servers for the built-in floating chat, independently of external agents.
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.
- Filesystem, Terminal, Memory, Fetch/Web, Sequential Thinking, Playwright, Git, GitHub, Docker, SQLite.
- Top-level **MCP Tool Servers** panel in Control Center → Integrations.
- Tiered browser: Starter, Terminal & Systems, Advanced.
- Tools run through an internal stdio MCP client in the main process.
- 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: OpenPets MCP Server Panel
## New: FamiliarOS MCP Server Panel — Your Familiar as a Tool for External Agents
A central panel in Control Center → Integrations configures the external-agent bridge.
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 pet routing in one place.
- Node path override and Familiar routing in one place.
- One-click **Test Server** health check.
- One-click **Copy MCP JSON** for Claude Code, Cursor, Codex CLI, or generic MCP hosts.
- 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: Pet Scale
## 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
- Continuous scale slider from 0.16x to 10x in Settings.
- Window dynamically resizes so the pet is never clipped.
- Window dynamically resizes so the familiar is never clipped.
## New: Virtual Pet Status
## New: Virtual Familiar Status
- 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.
- 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.
## New: Pet Character & Base Instructions
## New: Familiar 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."
- Give your pet a custom system-prompt style character in Settings.
- Toggle whether the default OpenPets behavior instructions are included in every chat.
## New: Text-to-Speech (Phase 2)
The pet can now speak aloud using a configurable TTS provider.
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 pet to speak.
- Plugins with the `voice:speak` permission can ask the familiar to speak.
## New: Multi-Screen Awareness
The pet now stays on the display it is currently near, rather than always snapping to the primary display.
The familiar 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 OpenPets desktop app. The new surfaces appear automatically:
No additional setup is required beyond the normal FamiliarOS desktop app. The new surfaces appear automatically:
- Single-click the pet → pet the virtual pet.
- Double-click the pet → floating chat.
- Right-click the pet → Virtual Pet status and care commands.
- Single-click the familiar → familiar the virtual familiar.
- Double-click the familiar → floating chat.
- Right-click the familiar → Virtual Familiar 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 → OpenPets MCP Server for external-agent bridge configuration.
- Integrations → Curated MCP Toolkit for optional persistent bundles and copy-paste snippets.
- Integrations → FamiliarOS MCP Server for external-agent bridge configuration.
- Integrations → MCP Toolkit Guide for optional persistent bundles and copy-paste snippets.

View file

@ -2,27 +2,27 @@
## Responsibility
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.
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.
## 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, pet controllers, catalog installation, setup flows, and plugin management/runtime.
- **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.
- **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 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.
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.
## Integration Points
- **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.
- **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.
- **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: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View file

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 397 KiB

View file

@ -1,41 +1,51 @@
{
"version": 2,
"generatedAt": "2026-05-05T08:55:53.722Z",
"pets": [
"familiars": [
{
"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": "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": "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": "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": "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": "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": "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": "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": "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"
"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
}
]
}

View file

@ -2,11 +2,11 @@
## Responsibility
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.
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.
## Design
- **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.
- **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.
- **Single Instance**: Uses `app.requestSingleInstanceLock()` with second-instance focusing
- **Security Model**:
- Sandboxed renderers with contextIsolation
@ -15,13 +15,13 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- 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)
- **Pet Architecture**:
- Default pet (always visible when enabled)
- Agent pets (lease-based, appear on explicit agent requests)
- Built-in fallback pet (bundled spritesheet)
- **Familiar Architecture**:
- Default familiar (always visible when enabled)
- Agent familiars (lease-based, appear on explicit agent requests)
- Built-in fallback familiar (bundled spritesheet)
- Speech bubbles with reaction messages and status badges
- User-configurable reaction-to-animation mapping
- **Lease Manager**: 15s TTL leases for agent pet routing with heartbeat renewal
- **Lease Manager**: 15s TTL leases for agent familiar 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 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
**Startup**: `main.ts``installAppLifecycle()``initializeAppState()``initializeLogger()``createAppTray()``startLocalIpcServer()` → initialize plugin service with JavaScript host/SDK bridge → optionally `showDefaultPet()`
**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
**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
**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/Pets/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/Familiars/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 pet/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 familiar/schedule/storage/command/status/network APIs
## Integration Points
- **Workspace Packages**: `@open-pets/agent-events`, `@open-pets/claude`, `@open-pets/cli`, `@open-pets/cursor`, `@open-pets/mcp`, `@open-pets/opencode`
- **Workspace Packages**: `@familiaros/agent-events`, `@familiaros/claude`, `@familiaros/cli`, `@familiaros/cursor`, `@familiaros/mcp`, `@familiaros/opencode`
- **External Services**:
- `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)
- `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)
- 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/openpets.mdc`
- Codex: `~/.codex/pets/` (local pet development)
- Cursor: `~/.cursor/mcp.json`, `.cursor/rules/familiaros.mdc`
- Codex: `~/.codex/familiars/` (local familiar development)
- IPC: Discovery file at platform-specific path, Unix socket/Windows named pipe/TCP
- Logs: `userData/logs/openpets.log`
- Logs: `userData/logs/familiaros.log`
- **Build**: `electron-builder` with ASAR, cross-platform (macOS/Windows/Linux)
## Key Files
@ -63,32 +63,32 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- `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, Pets, Integrations, Plugins, and Settings
- `renderer/`: React/Tailwind Control Center for Dashboard, Familiars, Integrations, Plugins, and Settings
- `local-ipc.ts`: TCP/Unix socket server for CLI communication
- `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
- `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
- `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`: `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-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-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-pet-api.ts`: Runtime bridge from plugin actions to default pet speech/reaction APIs
- `plugin-familiar-api.ts`: Runtime bridge from plugin actions to default familiar 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
- `pet-installation.ts`: Catalog ZIP download and extraction
- `codex-pets.ts`: Local Codex pet import
- `familiar-installation.ts`: Catalog ZIP download and extraction
- `codex-familiars.ts`: Local Codex familiar import
- `catalog.ts`: Remote catalog fetching with V3 pagination and fixture fallback
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet, state, tray, ui)
- `logger.ts`: Structured logging with scopes (app, ipc, lease, familiar, 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`/`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
- `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
- `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 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
## Test Structure
- **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/`.
- **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/`.
- **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,16 +3,25 @@ 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;
validateCatalogV2(fixture);
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.");
}
const generatedAt = new Date().toISOString();
const invalidCases: readonly unknown[] = [
{ 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" }] },
{ 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" }] },
];
for (const invalidCase of invalidCases) {

View file

@ -2,7 +2,7 @@
## Responsibility
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`.
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`.
## Design Patterns
@ -32,8 +32,8 @@ Test parseIpcRequest() with valid/invalid tokens, versions, methods
**Plugin Manifest Contract** (`plugin-manifest.contract.ts`):
```
Assert openpets.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and pet actions
Assert familiaros.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and familiar 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: "pets.list" }), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "familiars.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: "pet.install" }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, method: "familiar.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, "openpets.plugin.json");
assertEqual(openPetsPluginManifestFilename, "familiaros.plugin.json");
const validManifest = {
manifestVersion: 1,
@ -8,7 +8,7 @@ const validManifest = {
name: "Stretch Timer",
version: "1.0.0",
runtime: "declarative",
permissions: ["timer", "pet:speak", "pet:reaction"],
permissions: ["timer", "familiar:speak", "familiar: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: "pet.speak", message: "Time to stretch!" },
{ type: "pet.react", reaction: "celebrating" },
{ type: "familiar.speak", message: "Time to stretch!" },
{ type: "familiar.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: ["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");
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");
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: "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");
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");
assertInvalid({ ...validManifest, extra: true }, "unknown_field");
assertInvalid(
{
@ -64,7 +64,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: "Hi", unsafe: true }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.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", "pet:speak", "timer", "pet:reaction"] }, "duplicate_permission");
assertInvalid({ ...validManifest, permissions: ["timer", "familiar:speak", "timer", "familiar:reaction"] }, "duplicate_permission");
assertInvalid(
{
...validManifest,
@ -96,7 +96,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.badge", text: "Busy" }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.badge", text: "Busy" }] }],
},
"invalid_action",
);
@ -175,7 +175,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["pet:speak", "pet:reaction"],
permissions: ["familiar:speak", "familiar:reaction"],
},
"missing_permission",
);
@ -183,7 +183,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "pet:reaction"],
permissions: ["timer", "familiar:reaction"],
},
"missing_permission",
);
@ -191,7 +191,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "pet:speak"],
permissions: ["timer", "familiar:speak"],
},
"missing_permission",
);

View file

@ -1,82 +1,83 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
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),
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),
onRouteChange: (callback) => {
const listener = (_event, route) => callback(route);
ipcRenderer.on("openpets:control-center-route", listener);
return () => ipcRenderer.removeListener("openpets:control-center-route", listener);
ipcRenderer.on("familiaros:control-center-route", listener);
return () => ipcRenderer.removeListener("familiaros:control-center-route", listener);
},
onPluginsRefresh: (callback) => {
const listener = () => callback();
ipcRenderer.on("openpets:plugins-refresh", listener);
return () => ipcRenderer.removeListener("openpets:plugins-refresh", listener);
ipcRenderer.on("familiaros:plugins-refresh", listener);
return () => ipcRenderer.removeListener("familiaros:plugins-refresh", listener);
},
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),
getOpenPetsMcpServerPreview: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:openpets-mcp-server-preview", selectedPetId, commandMode),
testOpenPetsMcpServer: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:test-openpets-mcp-server", selectedPetId, commandMode),
getTtsSettings: () => ipcRenderer.invoke("openpets:get-tts-settings"),
getTtsVoices: (provider) => ipcRenderer.invoke("openpets:get-tts-voices", provider),
saveTtsCredential: (provider, credential) => ipcRenderer.invoke("openpets:save-tts-credential", provider, credential),
clearTtsCredential: (provider) => ipcRenderer.invoke("openpets:clear-tts-credential", provider),
testTtsSpeak: (text) => ipcRenderer.invoke("openpets:test-tts", text),
stopTts: () => ipcRenderer.invoke("openpets:tts-stop"),
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),
knowledgeList: (limit) => ipcRenderer.invoke("openpets:knowledge-list", limit),
knowledgeSearch: (query, limit) => ipcRenderer.invoke("openpets:knowledge-search", query, limit),
knowledgeDeleteFile: (id) => ipcRenderer.invoke("openpets:knowledge-delete-file", id),
knowledgeAddMemory: (text, kind, tags, importance) => ipcRenderer.invoke("openpets:knowledge-add-memory", text, kind, tags, importance),
knowledgeStoreFile: () => ipcRenderer.invoke("openpets:knowledge-store-file"),
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"),
};
contextBridge.exposeInMainWorld("familiarOSControlCenter", api);
contextBridge.exposeInMainWorld("openPetsControlCenter", api);

View file

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

View file

@ -28,23 +28,23 @@ const dismissBubble = (event) => {
bubble.remove();
const newTarget = document.elementFromPoint(event.clientX, event.clientY);
const stillInteractive = Boolean(newTarget && newTarget.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging || scaling;
const stillInteractive = Boolean(newTarget && newTarget.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling;
reportInteractiveHit(stillInteractive, "bubble-dismiss", true);
ipcRenderer.send("openpets:bubble-dismissed", dismissToken);
ipcRenderer.send("familiaros:bubble-dismissed", dismissToken);
};
const requestPromptWindow = (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".bubble")) return;
if (!target.closest(".pet-hitbox, .pet-shell")) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("openpets:pet-open-prompt");
ipcRenderer.send("familiaros:familiar-open-prompt");
};
ipcRenderer.on("openpets:pet-motion", (_event, state) => {
ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
if (!allowedMotionStates.has(state)) {
return;
}
@ -60,7 +60,7 @@ ipcRenderer.on("openpets:pet-motion", (_event, state) => {
}
});
ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
if (!allowedReactionStates.has(state)) {
return;
}
@ -76,7 +76,7 @@ ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
}
});
ipcRenderer.on("openpets:pet-content-state", (_event, state) => {
ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) {
return;
}
@ -95,13 +95,13 @@ ipcRenderer.on("openpets:pet-content-state", (_event, state) => {
const getInteractiveTarget = (event) => {
const target = document.elementFromPoint(event.clientX, event.clientY);
return target && target.closest(".pet-hitbox, .pet-shell, .bubble");
return target && target.closest(".familiar-hitbox, .familiar-shell, .bubble");
};
const reportInteractiveHit = (interactive, source, force = false) => {
if (!force && lastInteractiveHit === interactive) return;
lastInteractiveHit = interactive;
ipcRenderer.send("openpets:pet-hit-test", interactive, source);
ipcRenderer.send("familiaros:familiar-hit-test", interactive, source);
};
const setInteractiveHit = (interactive, source = "mouse") => {
@ -129,15 +129,15 @@ const applyScalePreview = (scale) => {
};
const sendScalePreview = (scale) => {
ipcRenderer.send("openpets:pet-scale-preview", { scale });
ipcRenderer.send("familiaros:familiar-scale-preview", { scale });
};
ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => {
ipcRenderer.on("familiaros:familiar-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(".pet-hitbox, .pet-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
reportInteractiveHit(Boolean(target && target.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
});
// --- Plugin bubble interactions (actions, inline inputs) -------------------
@ -159,7 +159,7 @@ const handleBubbleInteraction = (event) => {
if (actionButton) {
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("openpets:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
ipcRenderer.send("familiaros:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
return true;
}
const submitButton = target.closest("[data-bubble-submit]");
@ -167,20 +167,20 @@ const handleBubbleInteraction = (event) => {
event.preventDefault();
event.stopPropagation();
const bubble = submitButton.closest(".bubble");
ipcRenderer.send("openpets:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
ipcRenderer.send("familiaros:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
return true;
}
if (target.closest(".bubble-input-control")) return true;
return false;
};
// --- Pet senses: clicks, hover, drops ---------------------------------------
// --- Familiar senses: clicks, hover, drops ---------------------------------------
let lastHoverSentAt = 0;
let suppressClickUntil = 0;
const sendPetEvent = (name, payload) => {
ipcRenderer.send("openpets:pet-event", name, payload || {});
ipcRenderer.send("familiaros:familiar-event", name, payload || {});
};
const installPetSenses = () => {
@ -198,9 +198,9 @@ const installPetSenses = () => {
const flushClicks = () => {
resetClickTimer();
if (clickCount === 1) {
sendPetEvent("pet:clicked", {});
sendPetEvent("familiar:clicked", {});
} else if (clickCount === 2 && firstClickEvent) {
sendPetEvent("pet:doubleClicked", {});
sendPetEvent("familiar:doubleClicked", {});
requestPromptWindow(firstClickEvent);
}
clickCount = 0;
@ -211,7 +211,7 @@ const installPetSenses = () => {
if (event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest(".pet-hitbox, .pet-shell")) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
if (Date.now() < suppressClickUntil) return;
clickCount += 1;
@ -225,22 +225,22 @@ const installPetSenses = () => {
document.addEventListener("mouseover", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
const now = Date.now();
if (now - lastHoverSentAt < 2000) return;
lastHoverSentAt = now;
sendPetEvent("pet:hover", {});
sendPetEvent("familiar: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(".pet-hitbox, .pet-shell")) event.preventDefault();
if (target instanceof Element && target.closest(".familiar-hitbox, .familiar-shell")) event.preventDefault();
});
document.addEventListener("drop", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) return;
@ -251,19 +251,19 @@ const installPetSenses = () => {
sizeBytes: file.size,
text: file.size <= maxDropFileBytes ? await file.text().catch(() => "") : "",
truncated: file.size > maxDropFileBytes,
}))).then((read) => sendPetEvent("pet:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
}))).then((read) => sendPetEvent("familiar:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
return;
}
const text = String(transfer.getData("text/plain") || "").slice(0, maxDropTextBytes);
if (text) sendPetEvent("pet:drop", { kind: "text", text });
if (text) sendPetEvent("familiar:drop", { kind: "text", text });
});
};
// --- Plugin sprite/scale overrides ------------------------------------------
let spriteOverrideElement = null;
ipcRenderer.on("openpets:pet-sprite-override", (_event, override) => {
const shell = document.querySelector(".pet-shell");
ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
const shell = document.querySelector(".familiar-shell");
if (!shell) return;
const base = shell.querySelector(".sprite, .installed-card");
if (spriteOverrideElement) { spriteOverrideElement.remove(); spriteOverrideElement = null; }
@ -293,14 +293,14 @@ ipcRenderer.on("openpets:pet-sprite-override", (_event, override) => {
probe.src = override.fileUrl;
});
ipcRenderer.on("openpets:pet-scale-override", (_event, scale) => {
ipcRenderer.on("familiaros:familiar-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("openpets:pet-bubble-layout", (_event, layout) => {
ipcRenderer.on("familiaros:familiar-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 +320,7 @@ let activeAudioElements = [];
const audioLog = (level, message, fields) => {
try {
const safeFields = fields && Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
const line = `[openpets:pet-audio] ${message}`;
const line = `[familiaros:familiar-audio] ${message}`;
if (level === "warn") console.warn(line, safeFields || {});
else console.debug(line, safeFields || {});
} catch { /* diagnostics must never affect playback */ }
@ -342,7 +342,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("openpets:play-audio", (_event, payload) => {
ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
try {
if (!payload) return;
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
@ -385,7 +385,7 @@ ipcRenderer.on("openpets:play-audio", (_event, payload) => {
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
});
ipcRenderer.on("openpets:stop-audio", () => {
ipcRenderer.on("familiaros:stop-audio", () => {
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
activeAudioNodes = [];
@ -397,7 +397,7 @@ ipcRenderer.on("openpets:stop-audio", () => {
let currentTtsAudio = null;
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
@ -411,7 +411,7 @@ ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
@ -427,7 +427,7 @@ ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-stop", () => {
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();
@ -459,7 +459,7 @@ const installMouseInterop = () => {
document.addEventListener("mousemove", (event) => {
updateInteractiveHit(event);
if (dragging) {
ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY });
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;
@ -468,7 +468,7 @@ const installMouseInterop = () => {
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
dragCandidate = null;
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY });
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
}
}
if (scaling) {
@ -490,11 +490,11 @@ const installMouseInterop = () => {
scaleStartY = event.screenY;
scaleStartValue = getCurrentSpriteScale();
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-scale-start");
ipcRenderer.send("familiaros:familiar-scale-start");
return;
}
if (event.button !== 0 || !target?.closest(".pet-hitbox, .pet-shell")) return;
if (event.button !== 0 || !target?.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
dragStartPoint = null;
@ -508,7 +508,7 @@ const installMouseInterop = () => {
}
dragStartPoint = null;
dragCandidate = null;
ipcRenderer.send("openpets:pet-drag-end");
ipcRenderer.send("familiaros:familiar-drag-end");
} else if (dragCandidate) {
dragCandidate = null;
}
@ -516,7 +516,7 @@ const installMouseInterop = () => {
scaling = false;
const sprite = document.querySelector(".sprite, .installed-sprite");
const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue;
ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale });
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
}
});
@ -532,7 +532,7 @@ const installMouseInterop = () => {
scaleNudgeCommitTimer = null;
}
const finalScale = getCurrentSpriteScale();
ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale });
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
};
window.addEventListener("keydown", (event) => {
@ -544,13 +544,13 @@ const installMouseInterop = () => {
const current = getCurrentSpriteScale();
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
const next = applyScalePreview(current + delta);
ipcRenderer.send("openpets:pet-scale-preview", { scale: next });
ipcRenderer.send("familiaros:familiar-scale-preview", { scale: next });
if (scaleNudgeCommitTimer) clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = setTimeout(commitScaleNudge, 250);
});
setInteractiveHit(false, "ready");
ipcRenderer.send("openpets:pet-ready");
ipcRenderer.send("familiaros:familiar-ready");
};
if (document.readyState === "loading") {

View file

@ -1,23 +1,23 @@
{
"name": "@open-pets/desktop",
"name": "@familiaros/desktop",
"version": "3.1.0",
"private": true,
"description": "OpenPets tray-first desktop companion app.",
"description": "Create your Familiar. A local-first AI companion with memory, voice, knowledge, and tools.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/alvinunreal/openpets.git",
"url": "git+https://github.com/verticaltension/familiaros.git",
"directory": "apps/desktop"
},
"author": "OpenPets",
"author": "FamiliarOS",
"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 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",
"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",
"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": {
"@open-pets/plugin-sdk": "workspace:*",
"@familiaros/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",
"@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:*",
"@familiaros/agent-events": "workspace:*",
"@familiaros/claude": "workspace:*",
"@familiaros/cli": "workspace:*",
"@familiaros/cursor": "workspace:*",
"@familiaros/mcp": "workspace:*",
"@familiaros/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("--openpets-panel-token="));
const channel = tokenArg ? `openpets:plugin-panel:${tokenArg.slice("--openpets-panel-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-panel-token="));
const channel = tokenArg ? `familiaros:plugin-panel:${tokenArg.slice("--familiaros-panel-token=".length)}` : "";
const handlers = new Set();
ipcRenderer.on(`${channel}:message`, (_event, msg) => {
@ -10,8 +10,11 @@ ipcRenderer.on(`${channel}:message`, (_event, msg) => {
}
});
contextBridge.exposeInMainWorld("openPetsPanel", {
const api = {
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

@ -1,7 +1,10 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("openPetsCommandForm", {
const api = {
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,20 +4,27 @@ 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("--openpets-plugin-token="));
const channel = tokenArg ? `openpets:plugin-sdk:${tokenArg.slice("--openpets-plugin-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-plugin-token="));
const channel = tokenArg ? `familiaros:plugin-sdk:${tokenArg.slice("--familiaros-plugin-token=".length)}` : "";
let callbackId = 0;
const callbacks = new Map();
async function call(path, args) {
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
return ipcRenderer.invoke(channel, path, normalizeForIpc(args));
}
function callSync(path, args) {
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
const result = ipcRenderer.sendSync(channel, path, normalizeForIpc(args));
if (result && typeof result === "object" && typeof result.__openPetsError === "string") throw new Error(result.__openPetsError);
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);
}
return result;
}
@ -97,7 +104,7 @@ function wrapPickedFile(file) {
}
function wrapEventPayload(event, payload) {
if (event !== "pet:drop" || !payload || !Array.isArray(payload.files)) return payload;
if (event !== "familiar:drop" || !payload || !Array.isArray(payload.files)) return payload;
return {
...payload,
files: payload.files.map((file) => ({
@ -111,35 +118,35 @@ function wrapEventPayload(event, payload) {
function makePetHandle(petId) {
return {
id: String(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]),
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]),
};
}
const defaultPet = makePetHandle("default");
const sdk = {
pet: defaultPet,
pets: {
familiar: defaultPet,
familiars: {
default: defaultPet,
list: () => call("pets.list", []),
list: () => call("familiars.list", []),
get: (petId) => makePetHandle(petId),
spawn: (spec) => call("pets.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("pets.onChange", "pets.offChange", [], fn),
spawn: (spec) => call("familiars.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("familiars.onChange", "familiars.offChange", [], fn),
},
ui: {
bubble: (spec) => call("ui.bubble", [spec]).then(makeBubbleHandle),
@ -258,9 +265,13 @@ Object.defineProperty(sdk, "locale", {
get: () => callSync("i18n.locale", []),
});
contextBridge.exposeInMainWorld("__familiarOSSdk", sdk);
contextBridge.exposeInMainWorld("__openPetsSdk", sdk);
contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => {
const runCallback = 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,26 +1,26 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
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"),
storeKnowledgeFile: (file) => ipcRenderer.invoke("openpets:prompt-window-store-file", file),
log: (level, message) => ipcRenderer.send("openpets:prompt-window-log", level, message),
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),
};
contextBridge.exposeInMainWorld("familiarOSPromptWindow", api);
contextBridge.exposeInMainWorld("openPetsPromptWindow", api);
// --- TTS playback -------------------------------------------------------------
let currentTtsAudio = null;
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
@ -30,7 +30,7 @@ ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
@ -46,7 +46,7 @@ ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-stop", () => {
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();

View file

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

View file

@ -2,7 +2,7 @@
## Responsibility
Build and release automation scripts for the OpenPets desktop application. Handles packaging cleanup and local release orchestration (macOS-focused).
Build and release automation scripts for the FamiliarOS 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/openpets`
- **GitHub**: `gh release create`, `gh release upload` to `alvinunreal/familiaros`
- **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/openpets";
const repository = "alvinunreal/familiaros";
const allowedArgs = new Set([
"--dry-run",
@ -55,7 +55,7 @@ function main() {
preflight();
if (!skipChecks) {
run("pnpm", ["build"], { cwd: repoRoot });
run("pnpm", ["--filter", "@open-pets/desktop", "check"], { cwd: repoRoot });
run("pnpm", ["--filter", "@familiaros/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", `OpenPets ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--title", `FamiliarOS ${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 [
`OpenPets ${tag} ships the first official plugin release with polished desktop plugin management.`,
`FamiliarOS ${tag} ships the first official plugin release with polished desktop plugin management.`,
"",
"## New: OpenPets Plugins",
"## New: FamiliarOS Plugins",
"",
"OpenPets now includes a first-party plugin platform for bundled desktop companion behaviors.",
"FamiliarOS now includes a first-party plugin platform for bundled desktop companion behaviors.",
"",
"## Included plugins",
"",
"- Ambient Companion — gentle idle check-ins and ambient pet moments, enabled by default.",
"- Ambient Companion — gentle idle check-ins and ambient familiar moments, enabled by default.",
"- Break Buddy — friendly stretch, hydrate, and rest reminders, 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.",
"- 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.",
"- 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 OpenPets SDK.",
"- JavaScript plugins run in a sandboxed renderer with a narrow FamiliarOS SDK.",
"",
"## Developer notes",
"",

View file

@ -11,27 +11,116 @@ import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const preloadChecks = ["control-center-preload.cjs", "pet-preload.cjs", "plugin-sdk-preload.cjs", "panel-preload.cjs"];
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 behaviorTests = [
".test-dist/tests/lease-manager.test.js",
".test-dist/tests/default-pet-external-show.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/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-pets.test.js",
".test-dist/tests/codex-familiars.test.js",
".test-dist/tests/catalog-remote-seams.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",
@ -49,7 +138,7 @@ function run(command, args = [], options = {}) {
const child = spawn(command, args, {
stdio: "inherit",
cwd: rootDir,
env: { ...process.env, OPENPETS_DESKTOP_ROOT: rootDir },
env: { ...process.env, FAMILIAROS_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 OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.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";
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("pet.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
info("familiar.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
return false;
}
const window = getOrCreateAgentPetWindow(petId);
info("pet.agent", "show requested", { petId, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), activeWindows: agentPetWindows.size });
info("familiar.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("pet.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
debug("familiar.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
return;
}
info("pet.agent", "close requested", { petId, windowId: window.id, activeWindows: agentPetWindows.size });
info("familiar.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("pet.agent", "dismiss requested", { petId });
info("familiar.agent", "dismiss requested", { petId });
dismissedAgentPets.add(petId);
closeAgentPetIfOpen(petId);
}
export function clearAgentPetDismissal(petId: string): void {
debug("pet.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
debug("familiar.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
dismissedAgentPets.delete(petId);
}
export function clearAgentPetLeaseState(petId: string): void {
info("pet.agent", "lease state cleared", { petId, hadWindow: agentPetWindows.has(petId), wasDismissed: dismissedAgentPets.has(petId) });
info("familiar.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: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "reaction apply", { petId, reaction });
export function applyAgentPetReaction(petId: string, reaction: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
debug("familiar.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?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "say apply", { petId, reaction, messageLength: message.length });
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 });
if (!reaction) clearStatusBadge(petId);
setAgentDisplay(petId, { message, reaction });
const shown = showAgentPet(petId);
@ -75,7 +75,7 @@ export function applyAgentPetSay(petId: string, message: string, reaction?: Open
}
export function closeAllAgentPets(): void {
info("pet.agent", "close all requested", { activeWindows: agentPetWindows.size });
info("familiar.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("pet.agent", "refresh all content", { activeWindows: agentPetWindows.size, petIds: [...agentPetWindows.keys()] });
debug("familiar.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("pet.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
debug("familiar.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
if (dismissToken !== String(currentGeneration)) {
debug("pet.agent", "bubble dismissed stale token", { petId, dismissToken, currentGeneration });
debug("familiar.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("pet.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
debug("familiar.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
return existing;
}
const state = getAppStateSnapshot();
const scale = state.preferences.petScale as PetScaleValue;
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet) throw new Error(`Installed pet is unavailable: ${petId}`);
const familiar = state.familiars.installed.find((candidate) => candidate.id === petId);
if (!familiar) throw new Error(`Installed familiar 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: pet.displayName,
displayName: familiar.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("pet.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
info("familiar.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
});
agentPetWindows.set(petId, window);
info("pet.agent", "created", { petId, windowId: window.id, offset, activeWindows: agentPetWindows.size, position: { x: initial.x - offset * 36, y: initial.y - offset * 24 } });
info("familiar.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("pet.agent", "display set", { petId, reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
debug("familiar.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("pet.agent", "display cleared", { petId, hadDisplay: transientDisplays.has(petId), hadBadge: statusBadges.has(petId) });
debug("familiar.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: OpenPetsReaction): void {
function setStatusBadge(petId: string, reaction: FamiliarOSReaction): void {
if (reaction === "idle") {
clearStatusBadge(petId);
return;
}
statusBadges.set(petId, reaction);
debug("pet.agent", "status badge set", { petId, reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
debug("familiar.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: OpenPetsReaction): void {
}
function clearStatusBadge(petId: string): void {
if (statusBadges.has(petId)) debug("pet.agent", "status badge cleared", { petId, reaction: statusBadges.get(petId) });
if (statusBadges.has(petId)) debug("familiar.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: OpenPetsReaction): boolean {
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): 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("pet.agent", "scale changed", { scale });
debug("familiar.agent", "scale changed", { scale });
import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}).catch((error) => {
console.error("Failed to update pet scale preference.", error);
console.error("Failed to update familiar scale preference.", error);
});
}

View file

@ -0,0 +1,181 @@
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

@ -0,0 +1,157 @@
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

@ -0,0 +1,180 @@
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

@ -0,0 +1,149 @@
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

@ -0,0 +1,111 @@
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

@ -0,0 +1,198 @@
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

@ -0,0 +1,180 @@
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

@ -0,0 +1,152 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,130 @@
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,19 +1,41 @@
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 { app } from "electron";
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, buildOpenPetsMcpServerCommand, classifyClaudeMcpStatus, createOpenPetsHookSettingsPreview, doctorClaudeHooks, formatCommandForDisplay, 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 { createFamiliarOSHookSettingsPreview, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type OpenPetsStateV1 } from "./app-state.js";
import { doctorClaudeOpenPetsMemory, installClaudeOpenPetsMemory, uninstallClaudeOpenPetsMemory, type ClaudeOpenPetsMemoryStatus } from "./claude-memory.js";
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";
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 type JournalAction = "configure" | "update" | "replace" | "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 interface AgentSetupPetOption {
readonly id: string;
@ -36,13 +58,13 @@ export interface ClaudeCodeStatus {
export interface AgentSetupSnapshot {
readonly selectedPetId?: string;
readonly commandMode: OpenPetsCommandMode;
readonly commandMode: FamiliarOSCommandMode;
readonly localDevAvailable: boolean;
readonly petOptions: readonly AgentSetupPetOption[];
readonly preview: ClaudeMcpPreview;
readonly status: ClaudeCodeStatus;
readonly hookStatus: ClaudeHookDoctorResult;
readonly memoryStatus: ClaudeOpenPetsMemoryStatus;
readonly memoryStatus: ClaudeFamiliarOSMemoryStatus;
readonly opencodeStatus: OpenCodeSetupStatus;
readonly opencodePreview: OpenCodeSetupPreview;
readonly cursorStatus: CursorSetupStatus;
@ -58,44 +80,7 @@ export interface AgentSetupCommandPaths {
readonly opencode: string;
}
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 type { CursorSetupPreview, CursorSetupStatus, OpenCodeSetupPreview, OpenCodeSetupStatus } from "./agent-setup-editor-tools.js";
export interface AgentSetupActionResult {
readonly ok: boolean;
@ -104,28 +89,6 @@ 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;
@ -136,10 +99,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 = doctorClaudeOpenPetsMemory(app.getPath("home"));
const rawMemoryStatus = doctorClaudeFamiliarOSMemory(app.getPath("home"));
const memoryStatus = { ...rawMemoryStatus, claudeMdPath: formatUserPath(rawMemoryStatus.claudeMdPath) ?? rawMemoryStatus.claudeMdPath, openPetsMemoryPath: formatUserPath(rawMemoryStatus.openPetsMemoryPath) ?? rawMemoryStatus.openPetsMemoryPath };
const opencode = await getOpenCodeSetup(commandMode, petId);
const cursor = await getCursorSetup(commandMode, petId);
const opencode = await getOpenCodeSetup(commandMode, petId, runCommand, formatUserPath);
const cursor = await getCursorSetup(commandMode, petId, formatUserPath);
return {
selectedPetId: petId,
@ -154,27 +117,16 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandMode
opencodePreview: opencode.preview,
cursorStatus: cursor.status,
cursorPreview: cursor.preview,
commandPaths: getAgentSetupCommandPaths(),
commandPaths: getStoredAgentSetupCommandPaths(),
busy: operationRunning,
lastAction,
};
}
export function updateAgentSetupCommandPaths(patch: unknown): AgentSetupCommandPaths {
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();
return updateStoredAgentSetupCommandPaths(patch);
}
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);
@ -182,7 +134,20 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
operationRunning = true;
try {
lastAction = await runAction(action, petId, commandMode);
lastAction = await executeAgentSetupResolvedAction(action, {
selectedPetId: petId,
commandMode,
safeDoctorClaudeHooks,
safeBuildClaudeMcpPreview,
detectClaudeCodeStatus,
formatUserPath,
sanitizeOutput: sanitizeAgentSetupOutput,
summarizeCommandResult,
runCommand,
runClaudeCommand,
appendAgentSetupJournal,
journalActionFor,
});
operationRunning = false;
return getAgentSetupSnapshot(petId, commandMode);
} finally {
@ -190,755 +155,45 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
}
}
export function sanitizeAgentSetupOutput(value: string): string {
const home = app.isReady() ? app.getPath("home") : "";
return value
.replaceAll(home, "~")
.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "<path>")
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "<redacted-private-key>")
.replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
.replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=<redacted>")
.slice(0, 500);
function appendAgentSetupJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
writeActionJournal({
entry,
userDataPath: app.getPath("userData"),
formatUserPath,
sanitizeOutput: sanitizeAgentSetupOutput,
});
}
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 };
}
}
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");
}
export interface OpenPetsMcpServerPreview {
readonly commandMode: OpenPetsCommandMode;
readonly command: string;
readonly args: readonly string[];
readonly displayCommand: string;
readonly mcpJson: {
readonly mcpServers: {
readonly openpets: {
readonly type: "stdio";
readonly command: string;
readonly args: readonly string[];
};
};
};
}
export interface OpenPetsMcpServerHealth {
export interface FamiliarOSMcpServerHealth {
readonly ok: boolean;
readonly output: string;
readonly error?: string;
}
export function buildOpenPetsMcpServerPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): OpenPetsMcpServerPreview {
const nodeCommand = getPreferredNodeCommand();
const { command, args } = buildOpenPetsMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const displayCommand = formatCommandForDisplay({ command, args });
return {
commandMode,
command,
args,
displayCommand,
mcpJson: {
mcpServers: {
openpets: { type: "stdio", command, args },
},
},
};
}
export async function testOpenPetsMcpServer(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<OpenPetsMcpServerHealth> {
const nodeCommand = getPreferredNodeCommand();
const { command, args } = buildOpenPetsMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const result = await runCommand({ command, args: [...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 };
}
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,
};
export async function testFamiliarOSMcpServer(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<FamiliarOSMcpServerHealth> {
return runAgentSetupMcpServerHealthCheck(selectedPetId, commandMode, runCommand);
}
function validateSelectedPetId(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw new Error("Invalid selected pet id.");
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === value);
if (!pet || pet.broken) throw new Error("Selected pet is not installed or is broken.");
return pet.id;
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;
}
function validateCommandMode(value: unknown): OpenPetsCommandMode {
function validateCommandMode(value: unknown): FamiliarOSCommandMode {
if (app.isPackaged) return "bundled";
return value === "local" ? "local" : "published";
}
function getPetOptions(): readonly AgentSetupPetOption[] {
const state = getAppStateSnapshot();
return state.pets.installed.filter(isUsablePet).map((pet) => ({ id: pet.id, displayName: pet.displayName, default: pet.id === state.preferences.defaultPetId }));
return state.familiars.installed.filter(isUsablePet).map((familiar) => ({ id: familiar.id, displayName: familiar.displayName, default: familiar.id === state.preferences.defaultPetId }));
}
function isUsablePet(pet: InstalledPetState): boolean {
return pet.installed && !pet.broken && !pet.builtIn;
}
async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
for (const command of getClaudeCommandCandidates(spec.command)) {
const result = await runCommand({ command, args: spec.args });
if (result.ok || !isCommandNotFound(result)) return result;
}
return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: "Claude command was not found." };
}
function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
return new Promise((resolve) => {
const command = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
const args = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
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 isUsablePet(familiar: InstalledPetState): boolean {
return familiar.installed && !familiar.broken && !familiar.builtIn;
}
function journalActionFor(action: AgentSetupAction): JournalAction {
@ -949,5 +204,5 @@ function journalActionFor(action: AgentSetupAction): JournalAction {
export const agentSetupInternalsForChecks = {
sanitizeAgentSetupOutput,
createOpenPetsHookSettingsPreview,
createFamiliarOSHookSettingsPreview,
};

View file

@ -0,0 +1,96 @@
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

@ -29,7 +29,15 @@ export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): b
return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false;
}
export function markOnboardingCompleted<T extends { readonly preferences: Record<string, unknown> }>(state: T): T {
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 {
return {
...state,
preferences: {

View file

@ -0,0 +1,103 @@
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

@ -0,0 +1,139 @@
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

@ -0,0 +1,189 @@
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

@ -0,0 +1,98 @@
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,104 +1,54 @@
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { app } from "electron";
import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
import { builtInPet } from "./built-in-pet.js";
import { 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 type { Point } from "./display.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 {
normalizePreferences,
} from "./app-state-preferences.js";
import { publishPluginAgentActivity } from "./plugin-events-source.js";
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.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";
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 { defaultPetScale, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue };
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 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 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;
const stateFileName = "familiaros-state.json";
let statePath: string | null = null;
let currentState: OpenPetsStateV1 | null = null;
let currentState: FamiliarOSStateV1 | 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(readStateFile(statePath));
const nextState = normalizeState(readStateJsonFile(statePath));
writeStateToDisk(nextState);
currentState = nextState;
console.log(`OpenPets state initialized at ${statePath}.`);
console.log(`FamiliarOS state initialized at ${statePath}.`);
}
export function releaseStartupInstallLock(): void {
@ -107,11 +57,11 @@ export function releaseStartupInstallLock(): void {
if (lockPath) rmSync(lockPath, { recursive: true, force: true });
}
export function getAppStateSnapshot(): OpenPetsStateV1 {
export function getAppStateSnapshot(): FamiliarOSStateV1 {
return cloneState(getInitializedState());
}
export function updatePreferences(patch: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1 {
export function updatePreferences(patch: Partial<FamiliarOSStateV1["preferences"]>): FamiliarOSStateV1 {
const state = getInitializedState();
const preferences = normalizePreferences({ ...state.preferences, ...patch });
@ -128,23 +78,23 @@ export function isOnboardingCompleted(): boolean {
return getInitializedState().preferences.onboardingCompleted;
}
export function completeOnboarding(): OpenPetsStateV1 {
export function completeOnboarding(): FamiliarOSStateV1 {
const state = getInitializedState();
const nextState = normalizeState(markOnboardingCompleted(state));
commitState(nextState);
return getAppStateSnapshot();
}
export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
export function setDefaultPet(defaultPetId: string): FamiliarOSStateV1 {
const state = getInitializedState();
const targetPet = state.pets.installed.find((pet) => pet.id === defaultPetId);
const targetPet = state.familiars.installed.find((familiar) => familiar.id === defaultPetId);
if (!targetPet) {
throw new Error(`Cannot set unknown pet as default: ${defaultPetId}`);
throw new Error(`Cannot set unknown familiar as default: ${defaultPetId}`);
}
if (targetPet.broken) {
throw new Error(`Cannot set broken pet as default: ${defaultPetId}`);
throw new Error(`Cannot set broken familiar as default: ${defaultPetId}`);
}
const nextState = normalizeState({
@ -159,7 +109,7 @@ export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
export function setDefaultPetPosition(position: Point): FamiliarOSStateV1 {
const state = getInitializedState();
const nextState = normalizeState({
@ -174,7 +124,7 @@ export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 {
export function resetDefaultPetPosition(position: Point): FamiliarOSStateV1 {
return setDefaultPetPosition(position);
}
@ -182,45 +132,32 @@ export function getDefaultPetPosition(): Point | undefined {
return getInitializedState().defaultPet.position;
}
export function recordOpenPetsActivity(activity: OpenPetsActivityRecord, now: number = Date.now()): OpenPetsStateV1 {
export function recordFamiliarOSActivity(activity: FamiliarOSActivityRecord, now: number = Date.now()): FamiliarOSStateV1 {
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: {
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(),
},
analytics: buildRecordedActivityAnalytics(state.analytics, activity, now),
});
commitState(nextState);
return getAppStateSnapshot();
}
export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): OpenPetsStateV1 {
export function installPetState(familiar: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): FamiliarOSStateV1 {
const state = getInitializedState();
if (state.pets.installed.some((installedPet) => installedPet.id === pet.id)) {
throw new Error(`Pet is already installed: ${pet.id}`);
if (state.familiars.installed.some((installedPet) => installedPet.id === familiar.id)) {
throw new Error(`Familiar is already installed: ${familiar.id}`);
}
const nextState = normalizeState({
...state,
pets: {
familiars: {
installed: [
...state.pets.installed,
...state.familiars.installed,
{
...pet,
...familiar,
builtIn: false,
protected: false,
installed: true,
@ -233,16 +170,16 @@ export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protec
return getAppStateSnapshot();
}
export function removePetState(petId: string): OpenPetsStateV1 {
export function removePetState(petId: string): FamiliarOSStateV1 {
if (petId === builtInPet.id) {
throw new Error("Built-in pet cannot be removed.");
throw new Error("Built-in familiar cannot be removed.");
}
const state = getInitializedState();
const existing = state.pets.installed.find((pet) => pet.id === petId);
const existing = state.familiars.installed.find((familiar) => familiar.id === petId);
if (!existing) {
throw new Error(`Pet is not installed: ${petId}`);
throw new Error(`Familiar is not installed: ${petId}`);
}
const nextDefaultPetId = state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId;
@ -253,8 +190,8 @@ export function removePetState(petId: string): OpenPetsStateV1 {
...state.preferences,
defaultPetId: nextDefaultPetId,
},
pets: {
installed: state.pets.installed.filter((pet) => pet.id !== petId),
familiars: {
installed: state.familiars.installed.filter((familiar) => familiar.id !== petId),
},
});
@ -262,7 +199,7 @@ export function removePetState(petId: string): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function markPetBroken(petId: string, brokenReason: string): OpenPetsStateV1 {
export function markPetBroken(petId: string, brokenReason: string): FamiliarOSStateV1 {
const state = getInitializedState();
if (petId === builtInPet.id) {
@ -275,8 +212,8 @@ export function markPetBroken(petId: string, brokenReason: string): OpenPetsStat
...state.preferences,
defaultPetId: state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId,
},
pets: {
installed: state.pets.installed.map((pet) => pet.id === petId ? { ...pet, broken: true, brokenReason } : pet),
familiars: {
installed: state.familiars.installed.map((familiar) => familiar.id === petId ? { ...familiar, broken: true, brokenReason } : familiar),
},
});
@ -286,418 +223,25 @@ export function markPetBroken(petId: string, brokenReason: string): OpenPetsStat
export function getStateFilePath(): string {
if (!statePath) {
throw new Error("OpenPets app state has not been initialized.");
throw new Error("FamiliarOS app state has not been initialized.");
}
return statePath;
}
function getInitializedState(): OpenPetsStateV1 {
function getInitializedState(): FamiliarOSStateV1 {
if (!currentState) {
throw new Error("OpenPets app state has not been initialized.");
throw new Error("FamiliarOS app state has not been initialized.");
}
return currentState;
}
function readStateFile(path: string): unknown {
if (!existsSync(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
console.error(`Failed to read OpenPets state from ${path}; using defaults.`, error);
return undefined;
}
}
function normalizeState(value: unknown): OpenPetsStateV1 {
const record = isRecord(value) ? value : {};
const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {};
const preferencesRecord = isRecord(record.preferences) ? record.preferences : {};
const defaultState = createDefaultState();
const position = normalizeMaybePosition(defaultPetRecord.position);
const installedPets = normalizeInstalledPets(record);
const defaultPetId = typeof preferencesRecord.defaultPetId === "string"
&& installedPets.some((pet) => pet.id === preferencesRecord.defaultPetId && !pet.broken)
? preferencesRecord.defaultPetId
: builtInPet.id;
return {
version: 1,
preferences: normalizePreferences({
...defaultState.preferences,
...preferencesRecord,
defaultPetId,
}),
pets: {
installed: installedPets,
},
defaultPet: position ? { position } : {},
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,
ttsProvider: ["system", "openai", "elevenlabs", "piper", "openai-compatible"].includes(value.ttsProvider as string) ? (value.ttsProvider as OpenPetsStateV1["preferences"]["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: ["openrouter", "litellm", "wavespeedai", "custom"].includes(value.ttsEndpointPreset as string) ? (value.ttsEndpointPreset as OpenPetsStateV1["preferences"]["ttsEndpointPreset"]) : "openrouter",
ttsEndpoint: typeof value.ttsEndpoint === "string" ? value.ttsEndpoint : undefined,
};
}
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,
ttsProvider: "system",
ttsVoice: undefined,
ttsSpeed: 1,
ttsModel: undefined,
ttsEndpointPreset: "openrouter",
ttsEndpoint: undefined,
},
pets: {
installed: [builtInPet],
},
defaultPet: {},
analytics: {
messagesSent: 0,
reactionsSent: 0,
reactionCounts: normalizeReactionCounts(undefined),
perPetActivityCounts: {},
lastActivityAt: undefined,
},
};
}
function commitState(nextState: OpenPetsStateV1): void {
function commitState(nextState: FamiliarOSStateV1): void {
writeStateToDisk(nextState);
currentState = nextState;
}
function writeStateToDisk(state: OpenPetsStateV1): void {
const path = getStateFilePath();
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
}
function validateInstalledPetFiles(petId: string): string | undefined {
try {
const dir = getInstalledPetDir(petId);
const petJsonPath = join(dir, "pet.json");
const spritesheetPath = join(dir, "spritesheet.webp");
JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown;
const spritesheet = statSync(spritesheetPath);
if (!spritesheet.isFile()) return "spritesheet.webp is not a file.";
if (spritesheet.size <= 0) return "spritesheet.webp is empty.";
if (spritesheet.size > 100 * 1024 * 1024) return "spritesheet.webp is too large.";
return undefined;
} catch (error) {
return error instanceof Error ? error.message : "Installed pet files are invalid.";
}
}
function normalizeSource(value: unknown): InstalledPetState["source"] | undefined {
if (!isRecord(value)) {
return undefined;
}
if (value.kind === "codex" && typeof value.path === "string") {
return { kind: "codex", path: value.path };
}
if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined;
return {
kind: "catalog",
catalogVersion: 2,
zip: value.zip,
preview: value.preview,
};
}
function normalizeMaybePosition(value: unknown): Point | undefined {
if (!isRecord(value)) {
return undefined;
}
return normalizePosition(value);
}
function normalizePosition(value: Partial<Point>): Point | undefined {
if (typeof value.x !== "number" || typeof value.y !== "number") {
return undefined;
}
if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) {
return undefined;
}
return {
x: Math.round(value.x),
y: Math.round(value.y),
};
}
function cloneState(state: OpenPetsStateV1): OpenPetsStateV1 {
return structuredClone(state) as OpenPetsStateV1;
}
function 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;
function writeStateToDisk(state: FamiliarOSStateV1): void {
writeStateJsonFile(getStateFilePath(), state);
}

View file

@ -17,7 +17,7 @@ export function createAppIcon(): NativeImage {
return cachedAppIcon;
}
console.error(`OpenPets app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`FamiliarOS 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(`OpenPets tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`FamiliarOS 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("OpenPets tray icon creation produced an empty image.");
console.error("FamiliarOS 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 Pet",
displayName: "Built-in Familiar",
builtIn: true,
protected: true,
installed: true,

View file

@ -0,0 +1,195 @@
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

@ -0,0 +1,21 @@
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 pets: readonly CatalogPetV2[];
readonly familiars: readonly CatalogPetV2[];
}
export interface CatalogPetV2 {
@ -41,7 +41,7 @@ export interface CatalogV3Page {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly pets: readonly CatalogPetV3[];
readonly familiars: readonly CatalogPetV3[];
}
export interface CatalogV3SearchIndex {
@ -56,7 +56,7 @@ export interface CatalogV3SearchPage {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly pets: readonly CatalogV3SearchPet[];
readonly familiars: 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.pets)) throw new Error("Catalog pets must be an array.");
if (value.pets.length > 1000) throw new Error("Catalog has too many pets.");
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.");
const ids = new Set<string>();
const pets = value.pets.map((pet) => validateCatalogPet(pet, ids));
const familiars = value.familiars.map((familiar) => validateCatalogPet(familiar, ids));
return {
version: 2,
generatedAt: value.generatedAt,
pets,
familiars,
};
}
@ -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.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.");
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.");
const ids = new Set<string>();
const pets = value.pets.map((pet) => validateCatalogV3Pet(pet, ids));
const familiars = value.familiars.map((familiar) => validateCatalogV3Pet(familiar, ids));
return {
version: 3,
page: expectedPage,
pageSize,
pets,
familiars,
};
}
@ -169,36 +169,40 @@ 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.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.");
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.");
return {
version: 3,
page: expectedPage,
pageSize,
pets: value.pets.map((pet) => validateCatalogV3SearchPet(pet, catalogPageCount)),
familiars: value.familiars.map((familiar) => validateCatalogV3SearchPet(familiar, catalogPageCount)),
};
}
function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
if (!isRecord(value)) throw new Error("Catalog pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog familiar must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog pet id: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog familiar id: ${id}`);
ids.add(id);
return {
const entry: CatalogPetV2 = {
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 pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 familiar must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 pet id on page: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 familiar id on page: ${id}`);
ids.add(id);
const category = validateCategory(value.category);
const entry: CatalogPetV3 = {
@ -224,7 +228,7 @@ function validateCatalogV3Category(value: unknown): CatalogV3Category {
}
function validateCatalogV3SearchPet(value: unknown, catalogPageCount: number): CatalogV3SearchPet {
if (!isRecord(value)) throw new Error("Catalog v3 search pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 search familiar 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),
@ -244,7 +248,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 pet ${field} must be a boolean.`);
if (typeof value !== "boolean") throw new Error(`Catalog familiar ${field} must be a boolean.`);
return value;
}
@ -263,15 +267,15 @@ function validateInteger(value: unknown, field: string, min: number, max: number
}
function validateId(value: unknown): string {
if (typeof value !== "string") throw new Error("Catalog pet id must be a string.");
if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved.");
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog pet id: ${value}`);
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}`);
return value;
}
function validateString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`Catalog pet ${field} must be a string.`);
if (value.length > maxLength) throw new Error(`Catalog pet ${field} is too long.`);
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.`);
return value;
}
@ -284,10 +288,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 !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed.");
if (url.hostname !== "familiaros.dev" || !url.pathname.startsWith("/familiars/")) throw new Error("Preview URL host/path is not allowed.");
} else if (field === "catalog") {
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/")) {
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/")) {
throw new Error("Zip URL host/path is not allowed.");
}

View file

@ -1,20 +1,12 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
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 { 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 { catalogUrl, catalogV3Url } from "./catalog-remote.js";
export interface CatalogUiState {
readonly source: "remote" | "fixture" | "error";
readonly pets: readonly CatalogPetV2[];
readonly familiars: readonly CatalogPetV2[];
readonly generatedAt?: string;
readonly error?: string;
readonly version?: 2 | 3;
@ -29,34 +21,30 @@ export interface CatalogUiState {
export interface CatalogSearchUiState {
readonly source: "remote" | "error";
readonly pets: readonly CatalogV3SearchPet[];
readonly familiars: 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.index);
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value);
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
return {
source: "remote",
pets: filterSurfaceablePets(firstPage.pets),
generatedAt: remoteV3.index.generatedAt,
familiars: filterSurfaceablePets(firstPage.familiars),
generatedAt: remoteV3.value.generatedAt,
version: 3,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
total: surfaceableStats.total,
categories: remoteV3.value.filters.categories,
page: 0,
pageCount: surfaceablePageCount(remoteV3.index),
pageCount: surfaceableStats.pageCount,
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
};
}
@ -66,35 +54,36 @@ 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", 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 };
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 };
return {
source: "remote",
pets: filterSurfaceablePets(pageResult.pets),
generatedAt: remoteV3.index.generatedAt,
familiars: filterSurfaceablePets(pageResult.familiars),
generatedAt: remoteV3.value.generatedAt,
version: 3,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
total: surfaceableStats.total,
categories: remoteV3.value.filters.categories,
page,
pageCount: surfaceablePageCount(remoteV3.index),
pageCount: surfaceableStats.pageCount,
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
};
}
export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
try {
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
return { source: "remote", pets: surfacedPets, total: surfacedPets.length };
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value);
return { source: "remote", familiars: surfacedPets, total: surfacedPets.length };
} catch (error) {
return { source: "error", pets: [], error: error instanceof Error ? error.message : "unknown error" };
return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" };
}
}
@ -103,40 +92,39 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
if (remoteV3.ok) {
let blockedHiddenV3Pet = false;
try {
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
const searchPet = searchPets.find((pet) => pet.id === petId);
const searchPets = await getRemoteCatalogV3Search(remoteV3.value);
const searchPet = searchPets.find((familiar) => familiar.id === petId);
if (searchPet && !isSurfaceablePet(searchPet)) {
blockedHiddenV3Pet = true;
throw new Error(`Pet is not available in the curated catalog: ${petId}`);
throw new Error(`Familiar is not available in the curated catalog: ${petId}`);
}
if (searchPet) {
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
const pet = page.find((candidate) => candidate.id === petId);
if (pet && isSurfaceablePet(pet)) return pet;
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value);
const familiar = page.find((candidate) => candidate.id === petId);
if (familiar && isSurfaceablePet(familiar)) return familiar;
}
} catch (error) {
if (blockedHiddenV3Pet) throw error;
// Fall through to v2/fixture so visible v2-compatible pets remain installable during partial v3 outages.
// Fall through to v2/fixture so visible v2-compatible familiars remain installable during partial v3 outages.
}
}
const catalog = await getV2CatalogOrFixture();
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;
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;
}
async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<CatalogUiState> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) {
return {
source: "remote",
pets: filterSurfaceablePets(remote.catalog.pets),
generatedAt: remote.catalog.generatedAt,
familiars: filterSurfaceablePets(remote.value.familiars),
generatedAt: remote.value.generatedAt,
version: 2,
total: filterSurfaceablePets(remote.catalog.pets).length,
total: filterSurfaceablePets(remote.value.familiars).length,
supportsCategories: false,
};
}
@ -146,213 +134,39 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (fixture.ok) {
return {
source: "fixture",
pets: filterSurfaceablePets(fixture.catalog.pets),
generatedAt: fixture.catalog.generatedAt,
familiars: filterSurfaceablePets(fixture.value.familiars),
generatedAt: fixture.value.generatedAt,
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
version: 2,
total: filterSurfaceablePets(fixture.catalog.pets).length,
total: filterSurfaceablePets(fixture.value.familiars).length,
supportsCategories: false,
};
}
return {
source: "error",
pets: [],
familiars: [],
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
};
}
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,
function getSurfaceableSearchPets(familiars: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(familiars).map((familiar, surfaceIndex) => ({
...familiar,
catalogPage: Math.floor(surfaceIndex / index.pageSize),
}));
}
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 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 }> {
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: 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 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((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)) };
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)) };
} 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 @open-pets/cursor package
// These verify that the desktop app correctly uses the @familiaros/cursor package
const root = realpathSync(mkdtempSync(join(tmpdir(), "openpets-cursor-desktop-")));
const root = realpathSync(mkdtempSync(join(tmpdir(), "familiaros-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: {
openpets: {
familiaros: {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
},
},
};
@ -40,12 +40,12 @@ try {
const content = readFileSync(configPath, "utf8");
const parsed = JSON.parse(content);
assert.deepEqual(parsed.mcpServers.openpets, installedConfig.mcpServers.openpets);
assert.deepEqual(parsed.mcpServers.familiaros, installedConfig.mcpServers.familiaros);
// Test that desktop would preserve unrelated servers during operations
const multiServerConfig = {
mcpServers: {
openpets: installedConfig.mcpServers.openpets,
familiaros: installedConfig.mcpServers.familiaros,
other: { type: "stdio", command: "test", args: [] },
},
topLevelField: "preserve",
@ -61,7 +61,7 @@ try {
const publishedEntry = {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "test"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "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", "--pet", "test"],
args: ["/absolute/path/to/mcp.js", "--familiar", "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: {
openpets: {
familiaros: {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
},
},
};
assert.equal(preview.mcpServers.openpets.type, "stdio");
assert.equal(preview.mcpServers.openpets.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.openpets.args));
assert.equal(preview.mcpServers.familiaros.type, "stdio");
assert.equal(preview.mcpServers.familiaros.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.familiaros.args));
// Test desktop Phase 2 rules posture: preview/copy only, no project writes
const rulesPreview = {
rulesPath: ".cursor/rules/openpets.mdc",
rulesContent: "<!-- OPENPETS:CURSOR_RULES:START -->\nUse OpenPets sparingly.\n<!-- OPENPETS:CURSOR_RULES:END -->\n",
rulesPath: ".cursor/rules/familiaros.mdc",
rulesContent: "<!-- FAMILIAROS:CURSOR_RULES:START -->\nUse FamiliarOS sparingly.\n<!-- FAMILIAROS:CURSOR_RULES:END -->\n",
};
assert.equal(rulesPreview.rulesPath, ".cursor/rules/openpets.mdc");
assert.match(rulesPreview.rulesContent, /OPENPETS:CURSOR_RULES:START/);
assert.equal(rulesPreview.rulesPath, ".cursor/rules/familiaros.mdc");
assert.match(rulesPreview.rulesContent, /FAMILIAROS: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 "@open-pets/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
import { mapAsarPathToUnpacked } from "@familiaros/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
const root = mkdtempSync(join(tmpdir(), "openpets-desktop-opencode-"));
const root = mkdtempSync(join(tmpdir(), "familiaros-desktop-opencode-"));
try {
const globalDir = join(root, "opencode-global");
const bundledCli = mapAsarPathToUnpacked(join(root, "OpenPets.app", "Contents", "Resources", "app.asar", "node_modules", "@open-pets", "cli", "dist", "index.js"));
const bundledCli = mapAsarPathToUnpacked(join(root, "FamiliarOS.app", "Contents", "Resources", "app.asar", "node_modules", "@familiaros", "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, "openpets.md"));
assert.equal(install.instructionPath, join(globalDir, "familiaros.md"));
assert.match(install.configWrite.content, /app\.asar\.unpacked/);
assert.doesNotMatch(install.configWrite.content, /app\.asar(?!\.unpacked)/);
assert.match(install.configWrite.content, /@open-pets\/opencode@4\.5\.6/);
assert.match(install.configWrite.content, /@familiaros\/opencode@4\.5\.6/);
const preview = parseOpenCodeConfig(install.configWrite.content);
assert.equal(preview.ok, true, "desktop OpenCode preview must parse as JSONC without JSON.parse.");
const previewConfig = preview.value as { readonly mcp?: { readonly openpets?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] };
assert.deepEqual(previewConfig.mcp?.openpets?.command, ["node", bundledCli, "mcp", "--pet", "fixer"]);
assert.deepEqual(previewConfig.plugin, [["@open-pets/opencode@4.5.6", { pet: "fixer" }]]);
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" }]]);
writePreparedOpenCodeGlobalSetup(install);
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "installed");
assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/);
assert.match(readFileSync(join(globalDir, "familiaros.md"), "utf8"), /FAMILIAROS: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
* `@open-pets/plugin-sdk` type contract.
* `@familiaros/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 { OpenPetsContext, OpenPetsPermission } from "@open-pets/plugin-sdk";
import type { FamiliarOSContext, FamiliarOSPermission } from "@familiaros/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.pet, ctx.schedule, …) must
// Every namespace the published SDK exposes (ctx.familiar, ctx.schedule, …) must
// exist on the runtime API, and the runtime must expose nothing extra.
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof OpenPetsContext>>;
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof FamiliarOSContext>>;
// The JavaScript plugin permission union must match the published contract.
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, OpenPetsPermission>>;
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, FamiliarOSPermission>>;
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 ClaudeOpenPetsMemoryResult {
export interface ClaudeFamiliarOSMemoryResult {
readonly changed: boolean;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export interface ClaudeOpenPetsMemoryStatus {
export interface ClaudeFamiliarOSMemoryStatus {
readonly status: "installed" | "not_installed" | "error";
readonly message: string;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export const openPetsClaudeImportLine = "@~/.claude/openpets.md";
export const openPetsClaudeImportLine = "@~/.claude/familiaros.md";
const openPetsImportStart = "<!-- OPENPETS:IMPORT:START -->";
const openPetsImportEnd = "<!-- OPENPETS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- OPENPETS:START -->";
const openPetsMemoryEnd = "<!-- OPENPETS:END -->";
const openPetsImportStart = "<!-- FAMILIAROS:IMPORT:START -->";
const openPetsImportEnd = "<!-- FAMILIAROS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- FAMILIAROS:START -->";
const openPetsMemoryEnd = "<!-- FAMILIAROS:END -->";
const maxClaudeMemoryBytes = 1024 * 1024;
export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
export function installClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
mkdirSync(paths.claudeDir, { recursive: true, mode: 0o700 });
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
const nextOpenPetsMemory = upsertOpenPetsMemoryBlock(currentOpenPetsMemory, createOpenPetsMemoryBlock());
const openPetsChanged = currentOpenPetsMemory !== nextOpenPetsMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
const currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
const nextFamiliarOSMemory = upsertFamiliarOSMemoryBlock(currentFamiliarOSMemory, createFamiliarOSMemoryBlock());
const openPetsChanged = currentFamiliarOSMemory !== nextFamiliarOSMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
const currentClaudeMd = readTextFile(paths.claudeMdPath);
const nextClaudeMd = ensureManagedImport(currentClaudeMd);
@ -42,7 +42,7 @@ export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemo
return { changed: openPetsChanged || claudeMdChanged, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
export function uninstallClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
@ -55,18 +55,18 @@ export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMe
changed = true;
}
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
if (currentOpenPetsMemory) {
const nextOpenPetsMemory = removeOpenPetsMemoryBlock(currentOpenPetsMemory);
if (nextOpenPetsMemory.trim().length === 0) {
const currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
if (currentFamiliarOSMemory) {
const nextFamiliarOSMemory = removeFamiliarOSMemoryBlock(currentFamiliarOSMemory);
if (nextFamiliarOSMemory.trim().length === 0) {
if (hasUserOwnedImport) {
writePrivateTextFile(paths.openPetsMemoryPath, "");
} else {
rmSync(paths.openPetsMemoryPath, { force: true });
}
changed = true;
} else if (nextOpenPetsMemory !== currentOpenPetsMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
} else if (nextFamiliarOSMemory !== currentFamiliarOSMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
changed = true;
}
}
@ -74,26 +74,26 @@ export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMe
return { changed, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function doctorClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryStatus {
export function doctorClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryStatus {
const paths = getClaudeMemoryPaths(homeDir);
try {
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const claudeMd = readTextFile(paths.claudeMdPath);
const openPetsMemory = readTextFile(paths.openPetsMemoryPath);
const hasImport = hasManagedImport(claudeMd) || hasImportLineOutsideManagedBlock(claudeMd);
const hasInstructions = createOpenPetsBlockPattern().test(openPetsMemory) || /openpets_say|OpenPets MCP/i.test(openPetsMemory);
const hasInstructions = createFamiliarOSBlockPattern().test(openPetsMemory) || /familiaros_say|FamiliarOS MCP/i.test(openPetsMemory);
if (hasImport && hasInstructions) {
return { status: "installed", message: "Claude will load OpenPets instructions from ~/.claude/openpets.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "installed", message: "Claude will load FamiliarOS instructions from ~/.claude/familiaros.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasImport) {
return { status: "not_installed", message: "Claude imports OpenPets instructions, but the OpenPets memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "Claude imports FamiliarOS instructions, but the FamiliarOS memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasInstructions) {
return { status: "not_installed", message: "OpenPets instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "FamiliarOS instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
return { status: "not_installed", message: "Claude OpenPets instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "Claude FamiliarOS instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "Claude OpenPets instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "error", message: error instanceof Error ? error.message : "Claude FamiliarOS 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, "openpets.md"),
openPetsMemoryPath: join(claudeDir, "familiaros.md"),
};
}
export function createOpenPetsMemoryBlock(): string {
return `${openPetsMemoryStart}\n## OpenPets\n\nOpenPets MCP tools may be available.\n\nUse OpenPets as a short visible status channel for meaningful coding progress:\n- Use \`openpets_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`openpets_react\` for small visual or emotional feedback.\n- Use \`openpets_status\` only when checking availability or the targeted pet.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`;
export function 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 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 upsertOpenPetsMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
export function upsertFamiliarOSMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
return withoutBlocks ? `${withoutBlocks}\n\n${block}` : block;
}
export function removeOpenPetsMemoryBlock(source: string): string {
const withoutBlock = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
export function removeFamiliarOSMemoryBlock(source: string): string {
const withoutBlock = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
return withoutBlock ? `${withoutBlock}\n` : "";
}
function createOpenPetsBlockPattern(): RegExp {
function createFamiliarOSBlockPattern(): 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 OpenPets to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for FamiliarOS 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 OpenPets to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for FamiliarOS to update safely.");
}
function escapeRegExp(value: string): string {

View file

@ -2,17 +2,17 @@
## Responsibility
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.
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.
## Design
- **Modular Controllers**: Separate controllers for default pet vs agent pets (lease-based)
- **Modular Controllers**: Separate controllers for default familiar vs agent familiars (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, 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
- **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
- **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,55 +27,86 @@ 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() → handleRequest()
├── hello/status/pets.list/pets.install
local-ipc.ts → parseIpcRequest() → handleLocalIpcRequest()
├── hello/status/familiars.list/familiars.install
└── lease.acquire/heartbeat/release
└── lease-manager.ts
├── resolveTarget() (default vs explicit pet)
├── onFirstExplicitLease → agent-pet-controller.showAgentPet()
└── onLastExplicitLease → agent-pet-controller.closeAgentPetIfOpen()
├── resolveTarget() (default vs explicit familiar)
├── onFirstExplicitLease → agent-familiar-controller.showAgentPet()
└── onLastExplicitLease → agent-familiar-controller.closeAgentPetIfOpen()
└── Logging via logger.ts (ipc, lease scopes)
```
**Pet Display Flow**:
**Familiar Display Flow**:
```
pet-window.ts
familiar-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
└── pet-preload.cjs (renderer IPC for drag/click-through)
└── familiar-preload.cjs (renderer IPC for drag/click-through)
```
**Agent Setup Flow**:
```
windows.ts (IPC handlers)
└── agent-setup.ts
├── 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)
├── 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)
```
**Pet Installation Flow**:
**Familiar Installation Flow**:
```
pet-installation.ts
familiar-installation.ts
├── installPet()
│ ├── getCatalogPet() → catalog.ts
│ ├── downloadPetZip() → validate ZIP magic
│ ├── extractPetZip() → yauzl with entry validation
│ ├── familiar-installation-archive.ts
│ │ ├── downloadPetZip() → validate ZIP magic
│ │ └── extractPetZip() → yauzl with entry validation
│ └── installPetState() → app-state.ts
└── importCodexPet() → codex-pets.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
```
**Control Center Flow**:
@ -83,113 +114,256 @@ pet-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
├── Dashboard snapshot: default pet, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Pets/Integrations/Plugins/Settings
├── 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
```
**Plugin Flow**:
```
main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, ElectronPluginJsHost).start()
├── plugin-state.ts reads/writes userData/openpets-plugin-state.json
├── plugin-state.ts reads/writes userData/familiaros-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-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
│ │ └── 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)
└── lifecycle.ts → stopPluginService() on quit
Control Center plugins route:
tray.ts → openControlCenterWindow("plugins") → windows.ts → renderer React app
└── openpets:plugins-* IPC handlers call PluginService methods
└── familiaros: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-local-loader.ts validates selected folder manifest and snapshots only openpets.plugin.json to userData/plugins-dev/{id}
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
```
## Integration Points
- **Within src/**:
- `main.ts` → all modules (orchestrator), including `ElectronPluginJsHost` for JavaScript plugins
- `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
- `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
- `windows.ts``plugin-service.ts` for Control Center plugin UI IPC, plugin commands, and Dashboard plugin health
- `pet-window.ts` ↔ `default-pet-controller.ts`, `agent-pet-controller.ts`
- `pet-installation.ts` ↔ `app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `familiar-window.ts` ↔ `default-familiar-controller.ts`, `agent-familiar-controller.ts`
- `familiar-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/**:
- `@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
- `@familiaros/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
- `@familiaros/opencode`: `prepareOpenCodeGlobalSetup`, `doctorOpenCodeGlobalSetup`
- `@familiaros/cursor`: `planCursorMcpInstall`, `executeCursorMcpWrite`, `buildCursorRulesPreview`, etc.
- `@familiaros/cli`: Version lookup for bundled mode
- **To System**:
- 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/`
- 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/`
- 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 pet windows on quit
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and familiar windows on quit
- `state.ts`: Simple shell pause state
- `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
- `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
**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/pet/settings UI IPC endpoints, and scoped internal protocols
- `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
- `assets.ts`: Tray icon loading with generated fallback
- `display.ts`: Screen geometry helpers, pet window positioning
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Pets, Integrations, Plugins, and Settings.
- `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.
**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
**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
- `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 implementation, request routing, discovery file management, network security (loopback/private address filtering), logging
- `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-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**:
- `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
- `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.
- `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 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.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-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 (`openpets-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `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-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-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-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-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 OpenPets version.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum FamiliarOS 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, 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.
- `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.
**Agent Integration**:
- `agent-setup.ts`: Claude/OpenCode/Cursor detection, MCP configuration, hooks management, action journaling
- `claude-memory.ts`: Claude instructions file management (`~/.claude/openpets.md`)
- `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`)
- `update-checker.ts`: GitHub release polling, update status
- `update-version.ts`: Version parsing and comparison
@ -203,16 +377,16 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
| Source | Destination | Data |
|--------|-------------|------|
| Catalog API | `catalog.ts` | `CatalogV2/V3` JSON with pagination |
| 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 |
| 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 |
| `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/openpets.log` |
| All modules | `logger.ts` | Structured logs to `userData/logs/familiaros.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/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 |
| `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 |
| 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 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.");
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.");
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 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";
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";
const codexPetsRoot = join(homedir(), ".codex", "pets");
const codexPetsRoot = join(homedir(), ".codex", "familiars");
const codexThumbnailCache = new Map<string, string>();
let sharpFactoryPromise: Promise<any | null> | null = null;
export interface CodexPetUiState {
readonly source: "codex";
readonly pets: readonly CodexPetUiItem[];
readonly familiars: 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 pets: CodexPetUiItem[] = [];
const familiars: CodexPetUiItem[] = [];
let attemptedDirectories = 0;
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
attemptedDirectories += 1;
if (attemptedDirectories > maxCodexPets) break;
const pet = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (pet) {
pets.push(pet);
const familiar = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (familiar) {
familiars.push(familiar);
}
}
pets.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", pets };
familiars.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", familiars };
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", pets: [] };
return { source: "codex", pets: [], error: error instanceof Error ? error.message : "Codex pets unavailable." };
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", familiars: [] };
return { source: "codex", familiars: [], error: error instanceof Error ? error.message : "Codex familiars unavailable." };
}
}
export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
export async function importCodexPet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
throw new Error(`Familiar is already installed: ${petId}`);
}
const root = await validateCodexRoot();
@ -73,13 +73,13 @@ export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "familiar.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
await validateInstalledRegularFile(join(finalDir, "familiar.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: `openpets-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
spritesheet: `familiaros-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
};
} catch (error) {
console.error(`Skipping invalid Codex pet at ${dir}.`, error);
console.error(`Skipping invalid Codex familiar 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, "pet.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const petJson = join(dir, "familiar.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "familiar.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 pets root cannot be a symlink.");
if (!rootStats.isDirectory()) throw new Error("Codex pets path is not a directory.");
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.");
const realRoot = await realpath(root);
if (realRoot !== root) throw new Error("Codex pets root path is not canonical.");
if (realRoot !== root) throw new Error("Codex familiars root path is not canonical.");
return root;
}
async function assertCodexPetDirectory(root: string, target: string): Promise<void> {
const resolvedTarget = resolve(target);
if (resolvedTarget === root || !resolvedTarget.startsWith(`${root}${sep}`) || basename(resolvedTarget).startsWith(".")) {
throw new Error("Resolved path escapes Codex pets directory.");
throw new Error("Resolved path escapes Codex familiars directory.");
}
const dirStats = await lstat(resolvedTarget);
if (dirStats.isSymbolicLink()) throw new Error("Codex pet directory cannot be a symlink.");
if (!dirStats.isDirectory()) throw new Error("Codex pet path must be a directory.");
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.");
const realTarget = await realpath(resolvedTarget);
if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex pet directory escapes Codex pets root.");
if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex familiar directory escapes Codex familiars 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 pet file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported pet file must be a regular file.");
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 isNodeError(error: unknown): error is NodeJS.ErrnoException {

View file

@ -0,0 +1,10 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,116 @@
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

@ -0,0 +1,131 @@
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

@ -0,0 +1,190 @@
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

@ -0,0 +1,177 @@
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

@ -0,0 +1,200 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,32 @@
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

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

View file

@ -0,0 +1,199 @@
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

@ -0,0 +1,177 @@
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

@ -0,0 +1,57 @@
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

@ -0,0 +1,98 @@
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

@ -0,0 +1,157 @@
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

@ -0,0 +1,132 @@
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

@ -0,0 +1,87 @@
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

@ -0,0 +1,154 @@
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

@ -0,0 +1,336 @@
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

@ -1,454 +0,0 @@
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 pet currently lives on (the one nearest its centre),
// not the primary display — otherwise a pet on an external monitor gets yanked
// 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
// 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

@ -0,0 +1,232 @@
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

@ -0,0 +1,221 @@
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,12 +1,12 @@
import { BrowserWindow, screen } from "electron";
import { clampToVisibleWorkArea, defaultPetWindowSize, type Point } from "./display.js";
import { isPetWindowDragging } from "./pet-window.js";
import { isPetWindowDragging } from "./familiar-window.js";
/**
* Liveness motion primitives (§13.6): animated absolute moves, continuous
* cursor following with lag, and lightweight gravity/bounce physics. Operates
* on any pet window through an accessor (windows can be recreated), one loop
* on any familiar window through an accessor (windows can be recreated), one loop
* per surface, paused while hidden or being dragged.
*/
@ -96,7 +96,7 @@ function syncLoop(petHandleId: string, accessor: WindowAccessor, state: MotionSt
let nextY = y;
if (state.follow) {
const cursor = screen.getCursorScreenPoint();
// Aim the pet's bottom-center near the cursor; lag controls smoothing.
// Aim the familiar's bottom-center near the cursor; lag controls smoothing.
const targetX = cursor.x - Math.round(defaultPetWindowSize.width / 2);
const targetY = cursor.y - Math.round(defaultPetWindowSize.height * 0.7);
const smoothing = 1 - state.follow.lag;

View file

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

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