#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 | shWhat 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.plistwithKeepAlive, 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 | shLinux-only installer flags
| Flag | Effect |
|---|---|
| --download | Force the prebuilt path even inside a clone. |
| --from-source | Force a source build (needs cargo and libgtk-3-dev). |
| --layer-shell | Source build with Wayland layer-shell support. |
| --version vX.Y.Z | Install a specific release instead of the latest. |
| --autostart | Write the XDG autostart entry. |
| --uninstall | Stop 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.
| Phase | Means | Timing |
|---|---|---|
| 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 this | Do that |
|---|---|
| Open the session panel | Click the pet |
| Close the panel | Click the pet again, or click anywhere outside it |
| Move the pet | Drag it. Wherever you drop it becomes its new home. |
| Open the menu (panel, sound, hide, quit) | Right-click the pet |
| Watch something without interruptions | Right-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 it | Click that session's card in the panel |
| See a session's path, context tokens and last message | Right-click its card |
| Change species, toggle sound, toggle wandering | Panel, 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.
| Application | Precision | How |
|---|---|---|
| iTerm2, Terminal.app (macOS) | Exact tab | AppleScript match on the session's tty, so two tabs in one project are still told apart |
| Ghostty (macOS) | Exact tab or split | Each surface's live working directory, paired with the tab title |
| VS Code, JetBrains (macOS) | Window | Accessibility: 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) | Unavailable | No 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
| Command | Effect |
|---|---|
| ./install.sh | Build (or download) and run now. |
| ./install.sh --login-item | Also start at every login. On Linux this maps to --autostart. |
| ./install.sh --uninstall | Stop the pet and remove the login entry. State is kept. |
| git pull && ./install.sh | Rebuild and restart. The installer kills the old process first, so no uninstall step is needed. |
Running the binaries directly
| Command | Effect |
|---|---|
| ./native/SessionPet [scale] | macOS: run the pet at a pixel scale, default 5. |
| ./native/SessionPet --scan-once | Print 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 --once | Linux: one snapshot of the scanner, pretty-printed. |
| python3 linux/core.py --serve | Linux: what the Rust face runs, speaking NDJSON over stdin and stdout. |
| cd linux/face && cargo run -- 6 | Linux: run the face alone at scale 6. Needs a display. |
| ./test/run.sh | Build, 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
| Variable | Effect |
|---|---|
| SESSION_PET_HOME | Replaces $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_ROOT | Overrides where state, sounds and sprites are read from, including the pinned root on Linux. |
| SESSION_PET_LOG=1 | Writes 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.
| Key | Meaning |
|---|---|
| species | Sprite key, for example dragon. Set from the panel picker. |
| name | Your pet's name. Shows as ??? until the egg hatches. |
| form | Which unlocked evolution form to draw, for species that evolve. |
| sound | true or false. The panel checkbox and the right-click menu both write it. |
| walk | true or false: whether the pet wanders. |
| soundReady | Sound for a finished turn. An absolute path, or a bare filename resolved against sounds/. |
| soundInput | Sound for an agent that needs you, played twice in quick succession. |
| soundVolume | Playback 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_turnfor Claude Code,task_completefor Codex. A long tool run or a thinking pause never counts as done. - An
end_turnis 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 | From | Notes |
|---|---|---|
| egg | 0 XP | The pet has no name yet. Picking a sprite in settings hatches it immediately. |
| hatchling | 30 XP | The egg hatches on its own here. |
| adult | 200 XP | Species with an evolution rule change form at this stage. |
| legendary | 1000 XP | Highest 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.
#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.