Files
open-design/apps/daemon/src/codex-pets.ts
6fa2077651 feat(web): add pet companion with Codex hatch-pet integration (#296)
* feat(web): add pet companion with Codex hatch-pet integration

Introduces a customizable floating pet companion (overlay + entry-view rail
+ composer menu + dedicated Settings → Pets section) that supports built-in
pets, user customization (glyph/image/spritesheet), and one-click adoption
of pets packaged by the upstream Codex `hatch-pet` skill via a new
`/api/codex-pets` daemon endpoint. Vendors the unmodified `hatch-pet`
skill under `skills/hatch-pet/` and adds i18n strings across all locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(scripts): sync community Codex pets from public catalogs

Adds `pnpm sync:community-pets` which fetches all pets from
codex-pet-share.pages.dev (paginated Supabase Functions API) and
j20.nz/hatchery (single-shot JSON), then writes each one as
`<id>/pet.json` + `<id>/spritesheet.webp` under
`\${CODEX_HOME:-\$HOME/.codex}/pets/`. The existing daemon
`codex-pets` registry already scans that folder, so synced pets
appear under Settings → Pets → Recently hatched and adopt with one
click — no manual upload. Supports --source/--out/--force/--limit
flags and validates magic bytes so HTML error pages never end up
masquerading as `.webp` files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): tighten codex-pets validation and document vendoring

- sanitizeId now rejects ids that still contain `..` after collapsing,
  closing a defensive gap on the path-traversal guard for the
  `/api/codex-pets/:id/spritesheet` route.
- listCodexPets emits the sanitised folder name as the public id so the
  download route resolves directly against the on-disk folder, even when
  `manifest.id` differs (manual drops, sanitiser-touched manifests).
- Drop `@ts-nocheck` from `codex-pets.ts`; module is now strict-typed
  with explicit interfaces, an unknown-narrowed JSON.parse path, and a
  `pickString` helper guarding manifest fields one by one.
- Restrict the spritesheet response CORS header to sandboxed-iframe
  callers (Origin: null) instead of unconditional `*`, matching the
  existing raw-file route pattern. Same-origin web traffic does not
  need the header (web proxies `/api/*` through the daemon).
- Add `skills/hatch-pet/README.md` explaining the vendoring trade-off,
  provenance, and re-sync procedure.
- Add `docs/codex-pets.md` covering where pets live, how to populate the
  registry without Codex installed, and the manifest contract.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(i18n): add pet.* keys to Hungarian locale

Hungarian locale was added on main after this branch diverged, so the new
pet.* dictionary keys never landed there and tsc -b reports hu's Dict as
incomplete once main is merged in.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* feat(web): atlas-driven pet animations + bundled community pets

Builds on the existing pet companion (#296) with a richer animation
loop, a curated set of community pets that ship with the repo, and a
one-click sync into ~/.codex/pets/.

- Atlas-mode rendering: PetSpriteFace can now play the full Codex 8x9
  sprite atlas and swap rows from a JS-driven frame index. PetOverlay
  classifies pointer interactions (idle / hover / drag-direction /
  long-idle waiting) and maps them to the matching atlas row, so the
  pet waves on hover, runs on drag, and falls into a waiting pose
  after 6s of stillness. Single-strip pets keep their existing CSS
  steps() animation, with the steps timing fixed to jump-none so frame
  cells line up on cell boundaries.
- Atlas adoption: PetSettings exposes both "Use full atlas (animated)"
  and "Freeze to this row" — full mode keeps every row for the
  interaction state machine, single-row mode crops one strip via the
  existing canvas helper. New prepareCodexAtlas downscales the atlas
  to a localStorage-friendly PNG while preserving the grid layout.
- Settings tabs: pet sources are now split into Built-in / Custom /
  Community tabs so each origin gets its own dedicated surface.
- Bundled pets: scripts/bake-community-pets.ts seeds a curated set
  (clippit, dario, nyako-shigure, slavik, trump, tux, yelling-dario,
  yorha-sit-2b) into assets/community-pets/. The daemon scans this
  alongside the user's ~/.codex/pets/ root, with user pets winning
  when ids collide. CodexPetSummary gains a `bundled` flag so the UI
  can tag those cards with a "Bundled" pill.
- One-click community sync: daemon-side port of sync-community-pets
  exposed via POST /api/codex-pets/sync. Returns the same
  wrote/skipped/failed/total summary the CLI prints. Web Pet settings
  surface this as a "Download community pets" button under the
  Community tab.
- Avatar dropdown + hide rail: EntryView's avatar button is now a
  small menu (mirrors the project-view AvatarMenu) with toggles for
  hiding/showing the pet rail and opening Settings. PetRail gets a
  matching × button for the same hide flow.
- Locales: 7 new pet.* keys for tabs, sync, hide/show, atlas full
  mode, and the Bundled pill — translated into all 13 supported
  locales.

Typechecks pass across all workspace packages; daemon + web vitest
suites stay green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): bundled-pets built-in tab, ambient atlas animations, and community sync button

The Built-in tab now sources its catalog from the bundled spritesheets
at `assets/community-pets/` instead of the eight emoji placeholders that
felt boring next to the Codex hatch-pet atlases.

- Daemon: `listCodexPets` flags `bundled: true` by curated-set membership
  in `assets/community-pets/`, not by which folder the sprite happened to
  be read from. Previously a fully-synced user inbox preempted every
  bundled id and left the tab empty.
- Settings → Pets → Built-in renders the same sprite-card grid as
  Community, filtered by `bundled: true`, and reuses the existing
  `adoptCodexPet` flow. Community tab filters to non-bundled so the
  curated set never appears twice.
- Community tab gains the long-promised "Download community pets"
  trigger that calls `/api/codex-pets/sync` and shows an inline status
  line for the run summary. Strings already existed in every locale; we
  just plumbed the button.
- `PetOverlay` gets ambient atlas-row choreography — while idle, the
  overlay occasionally swaps `idle` for a random non-idle row (wave /
  hop / look) so the pet doesn't feel frozen. User gestures cancel the
  beat and take over instantly. `pickAmbientRow` lives next to
  `pickAtlasRow` so both row pickers share the fallback discipline.
- One-shot `migrateCustomPetAtlas` heals configs adopted before the
  overlay learned row switching by re-downloading the full spritesheet
  so hover / drag / ambient variety light up on next launch.
- `BUILT_IN_PETS` is now an empty array (the type stays for backwards
  compat); legacy configs whose `petId` still points at an emoji id
  (`mochi`, `pixel`, …) fall back to the user's custom slot in
  `resolveActivePet` so the overlay never renders blank.
- i18n: refresh `pet.tabBuiltInHint` (drop "emoji companions") and add
  `pet.builtInEmpty` across all locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 23:45:39 +08:00

279 lines
10 KiB
TypeScript

// Codex hatch-pet registry. Lists pets that the upstream `hatch-pet`
// skill packages under `${CODEX_HOME:-$HOME/.codex}/pets/<id>/` and the
// curated set bundled with this repo under `assets/community-pets/<id>/`.
//
// On-disk shape (per the hatch-pet `references/codex-pet-contract.md`):
//
// <root>/<id>/
// pet.json # { id, displayName, description, spritesheetPath }
// spritesheet.webp # 1536x1872 8x9 atlas (or .png / .gif fallback)
//
// We scan both folders lazily on every list request — there are only a
// handful of pets in either location, and watching the filesystem would
// add a daemon-side dependency that doesn't pay off here. When the same
// pet id exists in both, the user's local copy wins so re-baking a
// bundled pet locally is a supported workflow.
import { readdir, readFile, stat } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// Pre-scanned set of ids that live under the bundled `assets/community-pets/`
// root. We resolve the `bundled` flag against this set rather than against
// "which folder did we end up reading from", so a pet that exists in BOTH
// the bundled root and the user's `~/.codex/pets/` still surfaces as
// bundled (the sprite content can still come from the user's local copy
// — only the flag is determined by the curated set membership).
type BundledIdSet = Set<string>;
async function readBundledIds(root: string): Promise<BundledIdSet> {
const ids: BundledIdSet = new Set();
let entries: Dirent[] = [];
try {
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
} catch {
return ids;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const safeFolderId = sanitizeId(entry.name);
if (!safeFolderId) continue;
ids.add(safeFolderId);
}
return ids;
}
export interface CodexPetSummaryRecord {
id: string;
displayName: string;
description: string;
spritesheetUrl: string;
spritesheetExt: string;
hatchedAt: number;
// True when the pet was found in the bundled `assets/community-pets/`
// folder rather than the user's `~/.codex/pets/`. Surfaced so the UI
// can render a "Bundled" pill and skip prompting the user to sync
// pets that already ship with the app.
bundled?: boolean;
}
export interface CodexPetListResult {
pets: CodexPetSummaryRecord[];
rootDir: string;
}
interface PetManifest {
id?: unknown;
displayName?: unknown;
description?: unknown;
spritesheetPath?: unknown;
}
interface SpritesheetPick {
absPath: string;
ext: string;
}
export function resolveCodexPetsRoot(): string {
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
return path.join(home, 'pets');
}
const SPRITESHEET_NAMES = [
'spritesheet.webp',
'spritesheet.png',
'spritesheet.gif',
] as const;
// Scan a single root and append summaries to `out`. Pets already in
// `seenIds` are skipped — the user-root scan can therefore preempt a
// bundled pet of the same id without the bundled scan re-emitting a
// duplicate entry with a conflicting `bundled` flag.
//
// `bundledIds` lets us tag a pet as part of the curated set even when
// the sprite content was read from the user's local `~/.codex/pets/`
// copy. Without this, a user who synced every community pet via
// `pnpm sync:community-pets` would always preempt the bundled scan
// and the "Built-in" tab would render empty.
async function scanRoot(
root: string,
baseUrl: string,
bundledFallback: boolean,
bundledIds: BundledIdSet,
out: CodexPetSummaryRecord[],
seenIds: Set<string>,
): Promise<void> {
let entries: Dirent[] = [];
try {
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// The folder name is the on-disk identity for the pet — the
// `/api/codex-pets/:id/spritesheet` route resolves directly against
// it, so we use the sanitised folder name as the public id even
// when the manifest declares a different `id`. Mirroring the two
// would let a manifest typo (or a pet whose sanitised id differs
// from the folder name) silently 404 the download route.
const safeFolderId = sanitizeId(entry.name);
if (!safeFolderId) continue;
if (seenIds.has(safeFolderId)) continue;
const dir = path.join(root, entry.name);
const manifestPath = path.join(dir, 'pet.json');
let manifest: PetManifest = {};
try {
const raw = await readFile(manifestPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
manifest = parsed as PetManifest;
}
} catch {
// Manifest is optional — fall back to folder name for the
// display name so manually-dropped pets still appear.
}
const sheet = await pickSpritesheet(dir, manifest);
if (!sheet) continue;
let mtimeMs = 0;
try {
const st = await stat(sheet.absPath);
mtimeMs = st.mtimeMs;
} catch {
// ignore — listing should not fail on a transient stat error.
}
seenIds.add(safeFolderId);
const displayName = pickString(manifest.displayName) ?? prettyName(entry.name);
const description = pickString(manifest.description) ?? '';
const spritesheetUrl = `${baseUrl}/api/codex-pets/${encodeURIComponent(safeFolderId)}/spritesheet`;
// Curated-set membership wins over the source-folder default — a
// pet read from the user's `~/.codex/pets/` is still bundled if its
// id is part of `assets/community-pets/`.
const bundled = bundledIds.has(safeFolderId) ? true : bundledFallback;
out.push({
id: safeFolderId,
displayName,
description,
spritesheetUrl,
spritesheetExt: sheet.ext,
hatchedAt: Math.floor(mtimeMs),
bundled,
});
}
}
export async function listCodexPets(
options: { baseUrl?: string; bundledRoot?: string } = {},
): Promise<CodexPetListResult> {
const baseUrl = options.baseUrl ?? '';
const userRoot = resolveCodexPetsRoot();
const out: CodexPetSummaryRecord[] = [];
const seen = new Set<string>();
// Resolve the curated set membership up front so the user-root scan
// can stamp `bundled: true` on any local re-bake, and so the
// bundled-root scan only adds the curated pets the user has not
// already shadowed.
const bundledIds = options.bundledRoot
? await readBundledIds(options.bundledRoot)
: new Set<string>();
// User pets first so a locally re-baked copy preempts the bundled
// one (same id ⇒ user wins for sprite content).
await scanRoot(userRoot, baseUrl, false, bundledIds, out, seen);
if (options.bundledRoot) {
await scanRoot(options.bundledRoot, baseUrl, true, bundledIds, out, seen);
}
// Newest-first across both origins. Sorting by mtime keeps the
// "recently hatched" framing in the UI honest — a bundled pet from
// 2024 still sinks below a fresh user-hatched pet from this morning.
out.sort((a, b) => b.hatchedAt - a.hatchedAt);
return { pets: out, rootDir: userRoot };
}
// Returns { absPath, ext } for the resolved spritesheet of a given pet
// id, or null if the pet folder / sheet is missing. Used by the
// `/api/codex-pets/:id/spritesheet` route to safely serve the file —
// the id is sanitised on both sides so users cannot path-escape into
// arbitrary folders under their home directory or the bundled assets.
export async function readCodexPetSpritesheet(
id: string,
options: { bundledRoot?: string } = {},
): Promise<SpritesheetPick | null> {
const safeId = sanitizeId(id);
if (!safeId) return null;
const roots: string[] = [resolveCodexPetsRoot()];
if (options.bundledRoot) roots.push(options.bundledRoot);
for (const root of roots) {
const dir = path.join(root, safeId);
// Re-resolve the manifest so a manifest-declared spritesheetPath wins
// when it differs from our default name (matches the hatch-pet
// contract).
let manifest: PetManifest = {};
try {
const raw = await readFile(path.join(dir, 'pet.json'), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
manifest = parsed as PetManifest;
}
} catch {
// ignore; pickSpritesheet falls back to the canonical names.
}
const sheet = await pickSpritesheet(dir, manifest);
if (sheet) return sheet;
}
return null;
}
async function pickSpritesheet(dir: string, manifest: PetManifest): Promise<SpritesheetPick | null> {
const candidates: string[] = [];
const declaredPath = pickString(manifest.spritesheetPath);
if (declaredPath) {
// Resolve manifest path relative to the pet folder, then ensure it
// does not escape that folder.
const abs = path.resolve(dir, declaredPath);
if (abs.startsWith(dir + path.sep) || abs === dir) {
candidates.push(abs);
}
}
for (const name of SPRITESHEET_NAMES) {
candidates.push(path.join(dir, name));
}
for (const abs of candidates) {
try {
const st = await stat(abs);
if (!st.isFile()) continue;
return { absPath: abs, ext: path.extname(abs).slice(1).toLowerCase() || 'png' };
} catch {
continue;
}
}
return null;
}
// Strip anything that might let a request path-escape, then collapse
// runs of dots and reject any that still contain `..` after trimming —
// the daemon serves these ids straight into a `path.join`, and a value
// like `foo..bar` would otherwise be interpreted as `foo/../bar`.
// Mirrors the pet folder names produced by the upstream skill
// (lowercase + hyphens), but also accepts alphanumerics + a small set
// of safe punctuation to handle pets that users authored manually.
function sanitizeId(value: unknown): string {
const collapsed = String(value ?? '')
.replace(/[^a-zA-Z0-9._-]/g, '')
.replace(/\.+/g, '.')
.replace(/^[._-]+|[._-]+$/g, '')
.slice(0, 80);
if (collapsed.includes('..')) return '';
return collapsed;
}
function pickString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function prettyName(folder: string): string {
return folder.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}