A floating desktop mascot (pixel dog) that reacts to terminal and Claude Code activity in real-time. Ships on macOS; runs on Linux and WSL2 via a cross-platform backend facade. Built with Tauri 2 + React 19.
This project uses C3 architecture docs in .c3/. For architecture questions, changes, audits, or file ownership lookup, invoke /c3-skill:c3 with one of: query, audit, change, ref, rule, sweep.
- File → component lookup:
c3x lookup <file-or-glob>(use the c3-skill bin) - Topology:
c3x list(containers, components, refs, rules) - Validate:
c3x check(must be zero issues before merging architecture changes) - HARD RULE: never read or edit files under
.c3/directly — always go throughc3xCLI
Containers: c3-1 Rust Backend, c3-2 React Frontend, c3-3 Shell Integration. See .c3/README.md (via c3x read c3-0) for the system goal and the rules cited from each component.
- Dev:
bun run tauri dev - Build (macOS):
bun run tauri build && bash src-tauri/script/post-build-sign.sh - Build (Linux/WSL2):
bun run tauri build— produces AppImage + .deb insrc-tauri/target/release/bundle/ - Type check frontend:
npx tsc --noEmit - Type check backend:
cd src-tauri && cargo check - Package manager: Bun (not npm/yarn)
- Entitlements (macOS):
src-tauri/Entitlements.plist(network + Hardened Runtime); post-build re-sign is required for ad-hoc builds - Linux prerequisites:
sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libglib2.0-dev libayatana-appindicator3-dev librsvg2-dev zenity— required forcargo checkand runtime dialogs
See docs/ARCHITECTURE.md for full details. Key data flow:
Shell hooks (curl) → HTTP :1234 → Rust state → Tauri event → React UI
Claude Code ←stdio→ MCP server (Node.js) ←HTTP→ :1234 → Tauri event → React UI
Peer announces ←UDP :1235 (mDNS + multicast + unicast scan)→ AppState.peers → peers-changed event → UI
| Module | Responsibility |
|---|---|
lib.rs |
Tauri setup, plugin registration, tray icon, composition root, all #[tauri::command] functions |
state.rs |
AppState, Session, SessionInfo, resolve_ui_state(), emit_if_changed() |
server.rs |
HTTP server on 127.0.0.1:1234 (tiny_http), incl. MCP endpoints. Rejects status/heartbeat with 410 when the pid isn't alive |
watchdog.rs |
Background thread: service→idle transition, stale session cleanup |
proc_scan.rs |
Background thread (2s): libproc-based OS scan — auto-discovers shells, fills pwd/tty/fg_cmd, detects claude via KERN_PROCARGS2 argv, drops zombie sessions |
focus.rs |
focus_terminal_for_pid() — walks parent chain to find owning terminal app (iTerm/Terminal/VS Code/Cursor/tmux/etc.), activates via open -a, optionally targets tab via AppleScript |
discovery.rs |
mDNS peer discovery — registers _ani-mime._tcp.local., browses peers, emits peers-changed |
broadcast.rs |
UDP peer discovery on :1235 — multicast announce (224.0.0.200 every 5s), unicast /24 scan (every 30s), receive loop, expiry loop. Writes into the same AppState.peers as discovery.rs keyed by instance_name |
helpers.rs |
now_secs(), get_query_param() |
setup/mod.rs |
First-launch auto-setup orchestrator |
setup/shell.rs |
Shell detection, RC file injection, shell-selection prompt (via platform::show_dialog) |
setup/claude.rs |
Claude Code hooks configuration |
setup/mcp.rs |
MCP server installation + Claude Code MCP registration |
logger.rs |
Log file tail-reader, app_log!/app_warn!/app_error! macros |
platform/mod.rs |
Cross-platform facade: setup_main_window, set_dock_visibility, open_path, open_url, show_dialog, show_choose_list, open_local_network_settings, run_update_command |
platform/macos.rs |
macOS impl: Cocoa/objc window tweaks, osascript dialogs, open / brew --cask update flow |
platform/linux.rs |
Linux impl: Tauri-native transparency, zenity dialogs, xdg-open, manual update via release URL |
plugin/runtime.rs |
Spawns a per-plugin WebView (plugin://<id>/<entry>), injects the window.ani SDK, maps plugin id ↔ plugin-<id> window label |
plugin/gateway.rs |
plugin_call command — the single gated entry point for window.ani; checks declared capabilities, dispatches to storage / window ops |
plugin/storage.rs |
Per-plugin key/value store at ~/.ani-mime/plugins/<id>/data/store.json |
plugin/clipboard.rs |
Background OS-clipboard monitor (arboard) + history (deduped, capped 20, persisted to ~/.ani-mime/clipboard-history.json); runs only while a clipboard-capable plugin is enabled; exposed via the clipboard capability |
| File | Responsibility |
|---|---|
server.mjs |
Zero-dependency Node.js MCP server (JSON-RPC 2.0 over stdio) |
| Module | Responsibility |
|---|---|
App.tsx |
Root composition: layout + drag |
components/Mascot.tsx |
Sprite animation with auto-freeze |
components/StatusPill.tsx |
Colored dot + status label + session list dropdown (grouped by PWD) |
hooks/useStatus.ts |
Tauri "status-changed" + "mcp-react" event listener |
hooks/useDrag.ts |
Window drag via Tauri API |
hooks/useBubble.ts |
Speech bubbles: task-completed, welcome, "mcp-say" |
hooks/useDockVisible.ts |
Toggle dock visibility via set_dock_visible command |
hooks/useSessions.ts |
fetchSessions() wrapper around the get_sessions Tauri command |
hooks/useSessionList.ts |
Persistent setting: whether the pill-click session dropdown is enabled |
constants/sprites.ts |
Sprite file map, frame counts, auto-stop set |
types/status.ts |
Status type, SpriteConfig interface |
assets/claude-logo.png |
Claude Code icon shown on sessions running claude |
components/PluginManager.tsx |
Settings "Plugins" tab — install (.zip) / list / enable / disable / launch / uninstall installed plugins |
hooks/usePlugins.ts |
Loads plugins via get_plugins, refreshes on plugins-changed, exposes install/uninstall/setEnabled/launch actions |
types/plugin.ts |
PluginRecord / PluginManifest / PluginStatus TypeScript mirrors of the Rust types |
When multiple terminals are open, the UI shows one winner: busy > service > idle > disconnected
- Rust: Modules are flat files or directories with
mod.rs. Shared state usesArc<Mutex<AppState>>. - React: Functional components, hooks for logic. No state management library —
useState+ Tauri events. - CSS: Split by component (
styles/app.css,styles/mascot.css,styles/status-pill.css). Uses CSS custom properties for sprite animation. - Types:
Statusis the core shared type. Keep frontend and backend status strings in sync manually (no codegen yet). - Shell scripts: One per shell (
terminal-mirror.{zsh,bash,fish}). All usecurlto talk to:1234.
- HTTP server runs on
127.0.0.1:1234— this port is hardcoded in shell scripts, Claude hooks, and Rust server - Peer discovery runs on UDP
:1235(multicast group224.0.0.200+ unicast /24 scan) in addition to mDNS — all three channels feed the sameAppState.peersHashMap keyed byinstance_name, so peers discovered by multiple channels appear once - pid=0 is reserved for Claude Code hooks (virtual session)
- Heartbeats only refresh
last_seenfor non-busy sessions (prevents stuck commands from staying alive) /statusand/heartbeatreject with 410 when the pid isn't a live OS process — stops orphaned heartbeat subshells from re-registering dead sessions- Service state auto-transitions to idle after 2 seconds (watchdog)
- Sessions are removed after 40 seconds with no heartbeat (watchdog) OR immediately once
proc_scansees the PID is gone - Shell hooks send
pwd=$PWDandtty=$TTYon every/statusand/heartbeat;proc_scanfills the same fields authoritatively from libproc every 2 seconds proc_scanusesKERN_PROCARGS2sysctl to read argv[0] becauseclaudeshows up as"node"inp_comm(Claude Code is a Node binary)focus_terminalTauri command walks the parent chain via libproc + aps-based ppid fallback (needed because libproc refuses BSDInfo for root-ownedlogin)- Setup marker file:
~/.ani-mime/setup-done - OS-specific code lives under
src-tauri/src/platform/<target>.rswith a cfg-gated facade inplatform/mod.rs— callers useplatform::fn_name(...)and never see#[cfg(target_os = "...")]themselves - macOS uses
cocoa+objccrates for NSWindow tweaks (gated on#[cfg(target_os = "macos")]in Cargo.toml target deps) - Linux uses Tauri's built-in
transparent: truevia webkit2gtk;zenityprovides native dialogs (required package); dock visibility is a no-op since Linux has no global dock (skipTaskbar: truehandles the taskbar) - Tray icon is always present; left-click toggles main window, right-click shows menu (Show, Settings, Quit)
- "Hide from Dock" preference stored as
hideDockinsettings.json; on macOS appliesActivationPolicy::Accessoryat startup, on Linux it is a no-op - MCP server (
server.mjs) is installed to~/.ani-mime/mcp/on every startup; registered in~/.claude.jsonduring first-launch setup - MCP endpoints:
/mcp/say(speech bubble),/mcp/react(temp animation),/mcp/pet-status(JSON status) - MCP reactions map to existing statuses: celebrate/excited→service, nervous→busy, confused→searching, sleep→disconnected
- Plugin WebViews are created at runtime (
plugin::runtime::launch_plugin_webview), labeledplugin-<id>, and granted theplugincapability (src-tauri/capabilities/plugin.json, scoped to theplugin-*glob). Allwindow.anicalls route through the singleplugin_callcommand, which derives the plugin id from the calling window label — never from JS args — so plugins cannot impersonate each other. Window control (show/hide/resize/close) and per-plugin storage are mediated byplugin_call; thelaunch_plugincommand is the temporary trigger until the hotkey (Slice 3) and Plugin Manager UI (Slice 4) land.
- Writer:
tauri-plugin-logappends structured lines toani-mime.loginside the Tauri log dir (~/Library/Logs/<bundle-id>/on macOS,~/.local/share/<bundle-id>/logs/on Linux) - Reader:
logger.rsreads the tail of that same file to display in the Superpower Tool UI - Rotation: Configured as
KeepSome(3)with 1MB max per file — do not increase without reason - Tail-read:
read_log_file()seeks to the end of the file and reads only the last ~N×256 bytes. Never load the entire log file into memory. - Macros: Use
app_log!(),app_warn!(),app_error!()for app-level logging — these route through thelogcrate so the plugin writes them to file - Levels:
debugfor dev diagnostics,infofor state changes,warn/errorfor problems. Third-party crate noise is filtered inlib.rs(e.g.mdns_sdset toWarn) - Don't truncate the log file externally —
tauri-plugin-logholds its own file handle; truncating causes stale size tracking and premature rotation
Every interactive or observable UI element must be locatable by automated tests without coupling to styling or DOM structure.
- Always add
data-testidto any element that a test might need to find — buttons, inputs, status indicators, containers, cards, toggles, labels. When in doubt, add one. - Naming:
data-testid="section-element"(e.g.,settings-tab-appearance,pet-card-shiba,creator-save-btn). Use kebab-case. Parameterize with dynamic values where appropriate (pet-card-${id}). - Semantic HTML first: Use
<button>,<input>,<nav>,<main>,<label>— not styled<div>s. This enablesgetByRole()locators. - ARIA attributes: Add
aria-labelon icon-only buttons,role="switch"+aria-checkedon toggles, andhtmlForon<label>elements. These serve both accessibility and testability. - Never rely on CSS classes or DOM position for test selectors. Selectors like
.sidebar-item:nth-child(2)break when styling or order changes.
getByRole()— preferred, tests what users seegetByTestId()/[data-testid="..."]— explicit, stablegetByText()/getByPlaceholderText()— acceptable for unique visible text- Avoid:
container.querySelector(".class"), CSS class selectors,:nth-child()
- Unit tests (Vitest + React Testing Library):
src/**/*.test.{ts,tsx} - E2E tests (Playwright):
e2e/*.spec.ts - Run e2e:
bunx playwright test -c e2e/playwright.config.ts --project=chromium - Playwright config:
e2e/playwright.config.ts— chromium + webkit, trace on failure - When to run e2e: Before pushing. No pre-commit or pre-push hook is configured — run manually. E2e takes ~7s on Chromium; too slow for a commit hook.
- Tauri mock:
e2e/tauri-mock.ts— injects fake__TAURI_INTERNALS__for store, dialog, FS, window, and event plugins. Supports__MOCK_DIALOG_RESULT__,__MOCK_READ_FILE_BYTES__,__MOCK_READ_FILE_MAP__,__MOCK_SAVE_DIALOG_RESULT__,__MOCK_WRITTEN_FILES__,__MOCK_WINDOW_SIZES__for test assertions.
- New UI state: Update
Statustype →sprites.ts→StatusPill.tsx→status-pill.css→resolve_ui_state()instate.rs - New HTTP endpoint: Add route in
server.rs, lockAppStateif mutating, callemit_if_changed() - New Tauri command: Define
#[tauri::command] fninlib.rs, register in theinvoke_handler!macro. Domain logic may live in its own module (e.g.focus.rs), but the#[tauri::command]wrapper stays inlib.rs. - New MCP tool: Add tool definition in
mcp-server/server.mjs, add HTTP endpoint inserver.rs, emit Tauri event for frontend - New shell: Add script in
src-tauri/script/, addShellInfoinsetup/shell.rs, add totauri.conf.jsonbundle resources - New terminal app for click-to-focus: Add an entry to
classify_bundle()inproc_scan.rs, dispatch infocus.rswith either an existing strategy (open -a+ AppleScript tab selection) or a new strategy - New persistent setting: Follow the
useDockVisiblepattern — hook usesload("settings.json")+listen/emiton a*-changedevent; add a row inSettings.tsx - New peer-discovery channel: Create a new module in
src-tauri/src/(or extendbroadcast.rs). Spawn worker threads fromlib.rs::run()alongsidestart_discovery/start_broadcast. Write peers into the existingAppState.peersHashMap keyed byinstance_name— do not maintain a separate peers map. Emit the existingpeers-changedevent when the set mutates. If your channel has freshness/expiry semantics, track them in a dedicatedHashMap<String, u64>onAppState(seebroadcast_seenfor the pattern) and expire under a dedicated loop. Use the[your-channel]log tag and include aself-loop confirmedstyle health check where applicable. - Storage: See
docs/storage.mdfor the planned approach (tauri-plugin-store for prefs, SQLite for history)
Every version bump must update all 4 files — missing one causes the app to show stale version info:
| File | Field |
|---|---|
package.json |
"version" |
src-tauri/Cargo.toml |
version |
src-tauri/tauri.conf.json |
"version" |
src/components/Settings.tsx |
Hardcoded Version X.Y.Z string in About section |
After editing Cargo.toml, run cargo check in src-tauri/ to regenerate Cargo.lock.
- Bump version in all 4 files above + update
CHANGELOG.mdheader - Commit:
chore: release vX.Y.Z - PR → merge to main (branch protection requires PR)
- Tag on main:
git tag vX.Y.Z && git push origin vX.Y.Z - CI builds automatically — triggered by
v*tag push:create-releasejob runs first: callsgh release create --notes "$(gh api .../releases/generate-notes | grep -v '^\* chore: release v[0-9]')"to publish the GitHub release with an auto-generated "What's Changed" list of PRs merged since the previous tag, plus a Full Changelog compare link- The
grep -vfilter strips thechore: release vX.Y.ZPR bullet — every release has one by definition and it's noise in the user-facing changelog. If you add any other PR-title patterns that should always be hidden from release notes (docs-only churn, CI-only tweaks, etc.), extend the grep pattern inrelease.yml buildmatrix then builds aarch64 + x86_64 DMGs and uploads them to that same release- Write PR titles in conventional-commit style (
feat:,fix:,chore:, etc.) — they become the release-note bullet text verbatim
- Update Homebrew cask after CI publishes DMG artifacts:
- Download both DMGs:
gh release download vX.Y.Z --pattern "*.dmg" - Compute hashes:
shasum -a 256 *.dmg - Update
Casks/ani-mime.rbinvietnguyenhoangw/homebrew-ani-mimewith new version + SHA256s
- Download both DMGs:
- Branch:
release/vX.Y.Z - Tag:
vX.Y.Z - Commit message:
chore: release vX.Y.Z - DMG artifacts:
ani-mime_X.Y.Z_aarch64.dmg,ani-mime_X.Y.Z_x64.dmg - Homebrew tap:
vietnguyenhoangw/homebrew-ani-mime→Casks/ani-mime.rb