session-pet

A pixel-art desktop pet that watches every Claude Code and Codex session on one machine. It bounces while agents work, dings the moment one needs an answer, flags sessions that stall mid-turn, jumps you back to the terminal running any session, and levels up as sessions finish.

Native on both platforms: Swift with AppKit on macOS, Rust with GTK3 on Linux, sharing one pet, one XP pool and one .state/state.json. No Electron, no runtime dependencies beyond python3. This page is the tool's documentation; the repository holds the same material in README.md, INSTALL.md, CUSTOMIZING.md and ARCHITECTURE.md.

#Install

One installer covers both platforms. Inside a clone it builds from source; outside a clone it downloads a prebuilt binary from the latest GitHub release. Pick your platform:

macOS

Build from a clone

Needs Xcode Command Line Tools (for swiftc) and python3.

$ git clone https://github.com/chud-lori/session-pet.git
$ cd session-pet
$ ./install.sh --login-item   # build, run, and start at every login

Or take the prebuilt binary

Downloads session-pet-macos.tar.gz from the latest release into ~/.local/share/session-pet. Add -s -- --login-item to start it at login too.

$ curl -fsSL https://raw.githubusercontent.com/chud-lori/session-pet/main/install.sh | sh

What the installer does on macOS

  • Wraps the binary in native/SessionPet.app. This is not cosmetic: macOS only grants Automation permission to apps with a bundle identifier, and jump-to-terminal needs it. A bare executable is denied silently.
  • With --login-item, writes a LaunchAgent at ~/Library/LaunchAgents/com.session-pet.plist with KeepAlive, so the pet comes back if it dies.
  • Never edits ~/.claude/settings.json. The optional hook fragment is printed for you to merge yourself.
  • The first card click asks for permission to control your terminal. Decline it and the pet still raises the terminal app, it just cannot select the exact tab. Change your mind later in System Settings, Privacy and Security, Automation.

Remove it with ./install.sh --uninstall: that unloads and deletes the LaunchAgent and stops the running pet, leaving the repository and your pet's XP untouched.

Linux

Build from a clone

Needs cargo (via rustup.rs), the GTK3 headers and python3. On Ubuntu or Debian: sudo apt install libgtk-3-dev. The installer checks for the headers and tells you what to run; it never calls sudo itself.

$ git clone https://github.com/chud-lori/session-pet.git
$ cd session-pet
$ ./install.sh --autostart    # build, run, and start at every login

--login-item is the macOS spelling of the same flag and maps to --autostart here. The entry lands at ~/.config/autostart/session-pet.desktop.

Or take the prebuilt binary

Installs session-pet-linux-x86_64 or session-pet-linux-arm64 into ~/.local/bin. Prebuilts need python3 and the GTK3 runtime that GNOME, KDE and XFCE desktops already ship. Sound needs one of paplay, pw-play or ffplay, and is simply silent without them.

$ curl -fsSL https://raw.githubusercontent.com/chud-lori/session-pet/main/install.sh | sh

Linux-only installer flags

From linux/install.sh. The root installer forwards these.
FlagEffect
--downloadForce the prebuilt path even inside a clone.
--from-sourceForce a source build (needs cargo and libgtk-3-dev).
--layer-shellSource build with Wayland layer-shell support.
--version vX.Y.ZInstall a specific release instead of the latest.
--autostartWrite the XDG autostart entry.
--uninstallStop the pet, remove the binary, the autostart entry and the state-root pin.

Wayland

The pet needs always-on-top and self-positioning, which Wayland denies to ordinary clients, so the face pins the X11 backend (GDK_BACKEND=x11) and runs under XWayland by default. On KDE, sway or Hyprland you can build a real overlay surface with ./install.sh --from-source --layer-shell, at the cost of a fixed position and no dragging. Pure Wayland on GNOME has no layer-shell support, so stay on XWayland there.

Installing from a clone keeps state in the clone and pins that path in ~/.config/session-pet/root, so the autostart entry, a desktop launcher and a terminal run all open the same pet. Re-run the installer after moving a clone.

#The five session phases

Both providers are normalized into one set of phases, and the phase is what the pet's posture, its status dots and the panel cards all report. Pick a phase to see what it looks like on screen.

Thresholds are the constants in native/src/Config.swift and native/src/Scanner.swift.
PhaseMeansTiming
working Mid-turn. Also a fresh user prompt the agent has not answered yet, and a parent whose subagents are still writing. Read from the transcript, not from a clock.
ready The turn finished and nobody has looked yet. This is the quiet chime. Fades to idle after 3 minutes unacknowledged, rather than nagging forever.
input An agent is asking you something: AskUserQuestion or ExitPlanMode for Claude Code, request_user_input or elicitation_request for Codex, or a permission prompt seen through the optional hook. Double ping, repeated every 45 seconds up to 3 more times until you acknowledge it.
stalled Blocked mid-turn with no output: an unhooked permission prompt, a hung tool, a crash. Kept visible instead of vanishing. After 5 minutes of silence mid-turn.
idle / sleeping Quiet, or already acknowledged. Sessions age out of the list after an hour, unless their terminal is still open.

Status dots appear under the pet once two or more sessions are live, so one glance counts how many are working, finished, or waiting on you.

#Using the pet

The pet is a frameless always-on-top window with true per-pixel transparency, so clicks land only on its opaque pixels and pass through everywhere else.

To do thisDo that
Open the session panelClick the pet
Close the panelClick the pet again, or click anywhere outside it
Move the petDrag it. Wherever you drop it becomes its new home.
Open the menu (panel, sound, hide, quit)Right-click the pet
Watch something without interruptionsRight-click, then "Hide 30 min". Sounds keep working and the pet returns early only if an agent needs you.
Jump to the terminal running a session, and acknowledge itClick that session's card in the panel
See a session's path, context tokens and last messageRight-click its card
Change species, toggle sound, toggle wanderingPanel, then "settings"

Sounds

A quiet chime when a turn finishes, and a louder double ping when an agent needs you. The two have separate debounce clocks, so an urgent ping is never swallowed by a chime that just played. On macOS the defaults are the system Glass.aiff and Ping.aiff played through afplay; on Linux they are the freedesktop .oga theme sounds played through the first available of paplay, pw-play or ffplay. Both are replaceable, see configuration.

Muted, the pet still jumps on every alert and keeps a small reminder hop going with a "!" badge until you acknowledge it, because motion works when sound cannot.

Wandering

The pet takes short strolls along your screen with a walk cycle, never while something needs your attention and never while the panel is open. Sprite packs can ship hand-drawn walk frames; packs without them get an automatic two-frame leg shuffle. Turn it off in panel settings.

Jump to terminal

Clicking a session card raises the terminal running it, with no hook and no shell integration: the agent process is found in the process list and its parent chain is walked to the first GUI application. How precisely it lands depends on what that application exposes.

ApplicationPrecisionHow
iTerm2, Terminal.app (macOS)Exact tabAppleScript match on the session's tty, so two tabs in one project are still told apart
Ghostty (macOS)Exact tab or splitEach surface's live working directory, paired with the tab title
VS Code, JetBrains (macOS)WindowAccessibility: raise the window whose title contains the project
GNOME Terminal, Konsole and others (Linux, X11)Window_NET_WM_PID on the ancestor chain, then the window title
Wayland-native (Linux)UnavailableNo protocol exists for focusing another application's window

#Command reference

The pet helper

Works from anywhere inside the repository, on both platforms.

$ ./pet          # start, or bring it back after quitting
$ ./pet stop     # quit, the same as right-click then Quit
$ ./pet status   # is it running?

Installer

CommandEffect
./install.shBuild (or download) and run now.
./install.sh --login-itemAlso start at every login. On Linux this maps to --autostart.
./install.sh --uninstallStop the pet and remove the login entry. State is kept.
git pull && ./install.shRebuild and restart. The installer kills the old process first, so no uninstall step is needed.

Running the binaries directly

Useful when hacking on the pet or diagnosing a misread session.
CommandEffect
./native/SessionPet [scale]macOS: run the pet at a pixel scale, default 5.
./native/SessionPet --scan-oncePrint one JSON line per detected session, then exit. No window, no timers.
./native/SessionPet --jump <text>Resolve the first session whose project or title matches, print the process and host application it found, and jump there. Same code path as a card click.
python3 linux/core.py --onceLinux: one snapshot of the scanner, pretty-printed.
python3 linux/core.py --serveLinux: what the Rust face runs, speaking NDJSON over stdin and stdout.
cd linux/face && cargo run -- 6Linux: run the face alone at scale 6. Needs a display.
./test/run.shBuild, stage the fixture transcripts, and assert the phase each one classifies into.

The statusline pet (legacy)

pet.py renders a tiny pet inside the Claude Code statusline and shares XP, species and name with the desktop pet through .state/state.json. It is kept for compatibility; the native pets replaced it on both platforms, and pet_window.py, the original Tkinter window, is deprecated.

$ python3 pet.py species              # list species, current one marked
$ python3 pet.py set species dragon   # pick your pet
$ python3 pet.py set name Smaug       # rename it
$ python3 pet.py status               # XP and stage outside the statusline

#Configuration

Environment variables

VariableEffect
SESSION_PET_HOMEReplaces $HOME for transcript discovery, so the scanners read a fixture tree instead of your real one. This is what the test harness uses.
SESSION_PET_ROOTOverrides where state, sounds and sprites are read from, including the pinned root on Linux.
SESSION_PET_LOG=1Writes diagnostics to /tmp/session-pet.log on macOS and /tmp/session-pet-core.log on Linux, including every phase transition and each skipped sprite pack.

Keys in .state/state.json

The same file on both platforms. Species, name and the toggles are normally set from the panel; the sound keys have no UI and are edited by hand.

KeyMeaning
speciesSprite key, for example dragon. Set from the panel picker.
nameYour pet's name. Shows as ??? until the egg hatches.
formWhich unlocked evolution form to draw, for species that evolve.
soundtrue or false. The panel checkbox and the right-click menu both write it.
walktrue or false: whether the pet wanders.
soundReadySound for a finished turn. An absolute path, or a bare filename resolved against sounds/.
soundInputSound for an agent that needs you, played twice in quick succession.
soundVolumePlayback gain. Without it, needs-input plays at 1.6 and turn-end at 1.0.

A missing or unplayable sound file falls back to the platform default silently. For a pack that works on both platforms use .wav or .mp3: .aiff is unreliable on the Linux players, and afplay cannot play .oga.

Sprite packs

Drop a JSON file into sprites/, one species per file, picked up at the next launch and added to the picker. No rebuild, no export step. The species key is the filename stem, and a pack whose key matches a built-in replaces that sprite. Malformed files are skipped rather than fatal. sprites/example-slime.json ships as a working template.

{"name": "Slimey", "emoji": "🧼",
 "palette": {"X": "#7ee8a2", "k": "#2f4a33"},
 "rows": ["....kkkk....", "...kXXXXk..."]}

Rows are pixel strings and each character is a palette key. . is transparent; o, w and g are eye pixels, which the pet redraws as X while blinking, so use X for the main body color. Built-in sprites are 16 pixels wide and wider maps scale down to that footprint. An optional walk key holds an array of frames in the same shape as rows. Built-in sprites live in native/assets.json, the source of truth for both platforms.

Permission-prompt alerts (optional)

Claude Code permission prompts leave no trace in the transcript, so the pet cannot see them by reading files alone. This hook closes that gap. Merge it into ~/.claude/settings.json under "hooks" yourself, with the path pointing at your clone; the installer never edits your settings. It needs jq.

"Notification": [{"hooks": [{"type": "command", "async": true,
  "command": "jq -c . >> /path/to/session-pet/.state/events.jsonl"}]}]

The hook runs asynchronously and appends one JSON line per notification; the pet truncates that log itself so it cannot grow unbounded. Without the hook everything else still works, and permission prompts surface as stalled after a grace period instead of as an instant ping.

#How a phase is decided

No SDK and no daemon inside your agents. The pet reads the transcript files the agents already write: Claude Code's ~/.claude/projects/*.jsonl and Codex's ~/.codex/sessions/**/rollout-*.jsonl.

  • It tails, it does not read. Every couple of seconds only the last 64 KB of each recent transcript is read, growing once to 512 KB when no decisive event is in that window, with lossy UTF-8 decoding so a byte split across the chunk boundary can never nil the whole tail. Gigabyte-long sessions cost almost nothing.
  • Turn-end comes from the transcript, not the clock. A session is finished only when its last assistant event says so: stop_reason: end_turn for Claude Code, task_complete for Codex. A long tool run or a thinking pause never counts as done.
  • An end_turn is not always the end of a turn. A blocking Stop hook, or a queued message, continues the conversation seconds later. The scanner holds a short confirmation window and watches for the hook-feedback event that follows, so intermediate turn-ends stay silent and you get one ding, at the real end.
  • Subagents count as work. They write under <session-id>/subagents/**, where the parent transcript looks idle while the session is not, so their activity folds into the parent.
  • A quiet session stays listed while its terminal is open, which is read from the process list rather than guessed from the transcript.

You can watch the classifier work. ./test/run.sh stages the fixture transcripts in test/fixtures/ under an isolated fake home and runs one scan. Two of the six lines from a real run on this repository, with the absolute repository prefix shortened to .../:

$ SESSION_PET_HOME=test/home ./native/SessionPet --scan-once
{"doing":"needs your answer","phase":"input","path":".../test/home/.claude/projects/p1/ask-user.jsonl","label":"/Users/test/proj"}
{"doing":"1 subagent working…","phase":"working","path":".../test/home/.claude/projects/p1/subagent-parent.jsonl","label":"/Users/test/proj"}

The fixtures cover the cases that cost real debugging: a turn-end, an AskUserQuestion, a transcript line over 70 KB, a meta user event that must not read as a new turn, a Codex rollout, and a parent whose subagent is still running.

#XP, stages and species

Every session that finishes a turn banks 5 XP into the shared pool, and XP is what moves the pet through its stages. Nothing else feeds it: there are no streaks, quests or currencies.

Stage thresholds from native/src/State.swift.
StageFromNotes
egg0 XPThe pet has no name yet. Picking a sprite in settings hatches it immediately.
hatchling30 XPThe egg hatches on its own here.
adult200 XPSpecies with an evolution rule change form at this stage.
legendary1000 XPHighest stage, marked with a crown in the panel.

Before the pet hatches, the panel's XP line reads egg · pick a sprite in settings to hatch! rather than showing an empty bar. Once an evolved form is unlocked, an evolution dropdown appears in settings so you can roll back to an earlier form.

Species

Eleven species ship in the picker: cat, dragon, crab, octopus, dino, agumon, pudge, mario, fox, alien and turtle. The egg is a stage rather than a choice, and greymon is agumon's evolved form, earned at the adult stage rather than picked. Three of the sprites below are rendered straight from the pixel maps in native/assets.json, the same file the apps read.

cat
octopus
egg

#What it will not do

Kept deliberately out of scope, so the pet stays small:

  • No support for a long list of providers. Claude Code and Codex only.
  • No approving or denying permission prompts from the pet. It notifies, you act in the terminal.
  • No chatting with an agent from the pet window.
  • No menu-bar or notch rewrite. It stays a floating desktop pet.
  • No further gamification. XP, stages and species are the ceiling.

The rule behind the sounds: any false needs-input or ready ding is release-blocking. A false working is tolerable, but it has to be time-bounded, which is what the stalled phase exists for.

#Questions

Does it work with both Claude Code and Codex?

Yes, and that is the point. Each provider has its own tailer, and both are normalized to the same phases, so one pet and one panel cover every session on the machine. Deliberately only those two.

What do I lose if I skip the permission-prompt hook?

The instant alert for Claude Code permission prompts, and nothing else. Questions the agent asks through AskUserQuestion or ExitPlanMode still ping immediately, because those do appear in the transcript. A permission prompt without the hook looks like a session that has gone silent mid-turn, so it shows up as stalled after the grace period instead.

How do I mute it, and can I change the sounds?

Mute from the panel checkbox or the right-click menu; the setting persists in .state/state.json. To change the sounds, set soundReady and soundInput in that same file to a path, or to a filename inside sounds/. See configuration.

Why did my pet lose its level after I moved the clone?

The pet's identity is its state root, and a clone keeps its state inside itself. Moving the clone moves the state with it, but the pinned path in ~/.config/session-pet/root on Linux, and the LaunchAgent's recorded binary path on macOS, still point at the old location, so an autostarted pet can open a different (empty) state file. Re-run ./install.sh from the new location and both point at the same pet again. The current directory is deliberately never consulted: a pet whose level changed with your shell's working directory was a real bug.

Does it run on Wayland?

Under XWayland, yes, and that is the default on Ubuntu GNOME. Pure Wayland denies always-on-top and self-positioning to ordinary clients, so on KDE, sway or Hyprland you need a source build with --layer-shell, which fixes the pet in place and disables dragging. Jump-to-terminal stays X11-only either way, because Wayland has no protocol for focusing another application's window.

How do I uninstall it?

./install.sh --uninstall on either platform. On macOS it unloads and deletes ~/Library/LaunchAgents/com.session-pet.plist and stops the pet. On Linux it removes the binary from ~/.local/bin, the autostart entry and the state-root pin.

Your XP and species survive both. For a clean slate, delete the clone, or on Linux also remove ~/.local/share/session-pet. If you added the Notification hook, take that fragment out of ~/.claude/settings.json too.

Can the pet approve a permission prompt for me?

No, by design. The pet notifies and you act in the terminal. In-pet approve and deny, chatting with the agent, and a menu-bar rewrite are all explicit non-goals: it stays a small floating pet that never lies about session state.