- C++ 60.6%
- Python 29%
- CMake 10.4%
The agent's answer is drawn on the window and now said out loud. Three pieces: src/Speaker.* is the playback device, scripts/gobbo/tts.py is the synthesiser as a subprocess, and assistant.py speaks the reply when the turn ends on it. Speaker is Mic's mirror, one SDL_AudioStream, and two things are deliberately not symmetrical. The rate is an argument rather than pinned, because it belongs to the voice the synthesiser loaded and SDL converts anyway. And there is no hidden-window rule: the microphone refuses while hidden because the visible pet is the recording indicator, and a sound announces itself, so there is nothing for a window to indicate. That asymmetry is what completes the chord's gesture -- summon, ask, dismiss, and the answer arrives out loud with nothing left on screen to read. piper stays a subprocess rather than a linked library. Its native side wants onnxruntime plus libespeak-ng and its phoneme data, which is ~100MB per platform against an 11.8MB package, and our Windows build is mingw against msvcrt while onnxruntime ships MSVC/UCRT -- the runtime boundary docs/cross-compile.md already warns about. One process, kept warm: measured at 1.56s to first samples and 0.24s per utterance after that. An unspeakable answer is refused rather than truncated -- empty, over tts.max_chars, or built from things a listener cannot hear. The answer is on the pet's face either way, so a refusal costs nothing that was there, while reading half a sentence aloud as though it were whole is the lie the screen already declines to tell. tts.command has no default, but unlike the transcriber its absence is not an error: a pet that answers silently is what this script always did, so that goes in the log and a named voice that will not run goes on screen. Everything here is safe from the frame hook, and that is measured rather than hoped for. A 60fps loop under synthesis and playback drops no frames. speaker.clear() exists because closing and reopening a device to interrupt a sentence cost 60-100ms of the thread that draws. stop() terminates rather than waiting, because a graceful wait on a synthesiser mid-sentence is seconds of frozen window on the way out. And read1() replaced read(), which had been holding the tail of every sentence until the next one arrived to fill its buffer. Interrupting kills the worker. A raw PCM stream has no utterance boundary in it, and inferring one from a gap in arriving audio does not work: a synthesiser emits one sentence per burst, so the gaps inside a reply are the gaps between replies. That version was built and thrown away on the evidence -- five seconds after an interrupt, 546KB of abandoned speech was still queued and playing. A kill cannot be wrong, the replacement loads its voice while the user says the thing they interrupted for, and a worker never asked to speak is left alone. Verified by recording the sink monitor while the assistant answered, since no CI runner has speakers and this desktop has no second pair of ears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7yx1kxv218tdtFF6upcJ2 |
||
|---|---|---|
| .archive | ||
| .forgejo/workflows | ||
| .github/workflows | ||
| assets | ||
| cmake | ||
| docs | ||
| scripts | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| CMakeLists.txt | ||
| LICENSE | ||
| README.md | ||
| ROADMAP.md | ||
gobboclippy
A desktop pet host: embedded CPython inside a small C++/SDL3 program. The C++ side owns the window, the tray icon and the event loop. Everything above that is Python, and the whole thing ships as a relocatable directory you unzip and run.
This is the canonical base — the platform, build and distribution layer — plus the sprite, animation and text subset harvested from McRogueFace on top of it — plus a microphone, a listening assistant built on subprocesses that speak JSON lines, and a socket other processes can drive the pet through — including an agent. Speech bubbles, per-drawable hit testing and a config window are deliberately still absent; see docs/harvest.md for what was taken, what was cut, and why.
╭───────────────╮
│ scripts/*.py │ behaviour, policy, stdlib, your libraries
├───────────────┤
│ clippy │ extension module (src/PyClippy.cpp, src/PyDraw.cpp)
├───────────────┤
│ drawing │ Texture, Font, Sprite, Caption, Animation, Easing
├───────────────┤
│ C++ host │ window flags, tray, event loop, paths, mic, hotkey
├───────────────┤
│ SDL3 │ one dependency, four platforms
╰───────────────╯
The drawing layer knows nothing about Python — only PyDraw.cpp includes
Python.h — so it is a library the host happens to script, not a Python
program.
Why this shape
The alternative was a frozen Python environment (PyInstaller + PySide6 or tkinter). The measurement that settled it: the size difference between those options is Qt, not the language. CPython is ~10 MB either way. Dropping Qt for SDL3 is what gets the package under 20 MB, and SDL3 is also the only option with the full window capability set plus a native tray on all three desktop platforms.
Platform support
Determined from SDL 3.4.16's own sources (src/video/*/, src/tray/*/), not
assumed. The running binary re-checks and reports the real answer:
$ ./gobboclippy --capabilities
gobboclippy 0.6.0 (SDL 3.4.16, Python 3.14)
platform : Linux
video driver : x11
borderless : yes
always on top : yes
transparent : yes
skip taskbar : yes
tray icon : yes
microphone : yes
speaker : yes
global hotkey : yes
hotkey release: yes
image formats : png, webp
| Windows | macOS | X11 | Wayland | |
|---|---|---|---|---|
| borderless | yes | yes | yes | yes |
| per-pixel transparency | yes | yes | yes | yes |
| always on top | yes | yes | yes | no |
| skip taskbar | yes | yes | yes | yes |
| tray icon | yes | yes | GTK3 + appindicator | GTK3 + appindicator |
| global hotkey | yes | yes | yes | no |
| hotkey key release | no | yes | yes | — |
Three caveats worth knowing before you file a bug:
Wayland's xdg-shell has no always-on-top, and no positioning either.
SDL's Wayland backend registers no always-on-top hook at all, yet
SDL_SetWindowAlwaysOnTop still returns success and the flag reads back as
set — so Capabilities cross-checks the video driver rather than trusting
either. SDL_SetWindowPosition fails outright, which matters more for a pet:
it cannot place itself. This is the same ceiling Electron hits.
It is not a security restriction and it is not unsolvable — wlr-layer-shell
is the unprivileged protocol that panels and docks use, SDL supports handing
it a roleless surface, and it maps onto a pet cleanly. It just does not exist
on GNOME. X11/XWayland is the supported Linux path, and gets the full
capability set on every desktop including GNOME and KDE under Wayland. See
docs/wayland.md for the route if that changes.
The Linux tray dlopens GTK3 and libayatana-appindicator3 at runtime. There is no build-time dependency, but a machine without them gets no tray. Because the window is borderless with no close button, the app treats a missing tray as fatal rather than starting unreachable:
sudo apt install libgtk-3-0 libayatana-appindicator3-1
Windows reports a global hotkey being pressed and never reports it being
released. RegisterHotKey posts WM_HOTKEY on the press and there is no
corresponding message — not a missing flag, the API simply has one event. X11
and macOS report both. That is why the capability report answers two questions
instead of one: a hotkey that toggles something works everywhere, and anything
that needs to know when the key came back up has to ask
clippy.hotkey.delivers_release() first rather than wait for an event that is
not coming. Synthesising the release from a timer was the alternative, and a
held key that lets go on its own is worse than a documented gap.
Deliberately not implemented
Click-through, and per-drawable hit testing. The window is a rectangle that
swallows clicks over its whole area, transparent corners included. Shaped input
regions (wl_surface.set_input_region and friends) are where the hover/drag/
click state machine gets genuinely hard, and skipping them removes the worst
cross-platform cliff. Transparency makes it look like a paperclip; it is
still a rectangle. So a click reaches the script as "the pet was clicked", with
window coordinates — McRogueFace's click_at / hover dispatch is still cut, and
drawables have no hit testing.
Speech bubbles. A Caption on a transparent window sits on whatever the
user's wallpaper happens to be, so there is no background colour to pick a
readable ink against; scripts/clippy.py draws its text twice, offset, as a
drop shadow. A real bubble wants a nine-slice Frame, which is the next thing
to harvest if it is wanted.
Build
Needs CMake ≥ 3.21, a C++17 compiler, and Python ≥ 3.10 with development headers — 3.10 through 3.14 are supported, and a native build bundles whichever one it finds. Released packages ship 3.14; that version is pinned in CI, not here. SDL3 and stb are fetched and pinned automatically.
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/gobboclippy
Linux also needs the SDL3 build dependencies, or you get a binary with no X11
or Wayland backend. libx11-dev is required outright rather than optionally:
the global hotkey backend calls XGrabKey directly, so unlike SDL — which
dlopens X11 — this binary links libX11.so.6. That is a declaration of
something already mandatory rather than a new requirement, because the tray is
fatal if missing and both GTK3 and libayatana-appindicator3 link libX11
themselves.
sudo apt install libx11-dev libxext-dev libxrandr-dev libxcursor-dev \
libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libwayland-dev \
wayland-protocols libdecor-0-dev libgtk-3-dev libayatana-appindicator3-dev
Building against a local SDL checkout instead of the fetched one:
cmake -B build -DFETCHCONTENT_SOURCE_DIR_SDL3=/path/to/SDL
Package
cmake --build build --target package
Produces a relocatable directory and an archive:
gobboclippy-0.6.0-Linux/
gobboclippy 1.6 MB
python3 -> gobboclippy the same binary, dispatched on argv[0]
libSDL3.so.0 3.4 MB
assets/ SVG sources + rendered PNGs + JetBrains Mono
scripts/ clippy.py, assistant.py, pet_demo.py, pet_ctl.py, gobbo/
licenses/ notices for everything redistributed here
lib/
libpython3.14.so.1.0 5.8 MB the SONAME, not the linker name
python314.zip 2.7 MB stdlib, test suites excluded
python3.14/lib-dynload/ stdlib C extensions
pip-26.2.1-py3-none-any.whl pip, importable straight from the wheel
site/ sys.prefix; pip installs under here
23 MB on disk, 11 MB compressed, measured from the release artifact rather
than a local build — the two disagree about which pip ships, and a local
libpython is whatever the build machine's distro made. The binary's RUNPATH is
$ORIGIN:$ORIGIN/lib, and every runtime path is resolved from
SDL_GetBasePath(), so the directory can be moved anywhere. Verified by
running it from a different filesystem with env -i.
Both shipped libraries are staged under their SONAME — libSDL3.so.0,
libpython3.14.so.1.0 — because that is the name the loader searches for, and
a package that gets it wrong does not fail. It falls through to the host's copy
and works on every machine that has one, which is every machine that builds or
tests it. CI relocates the package, clears LD_LIBRARY_PATH and asserts that
ldd resolves both inside the tree, because running the binary cannot tell the
difference.
Packaging strips every binary in the staging tree, and this is not cosmetic:
the interpreter is somebody else's build, and whether it arrives stripped is
not something the project gets to assume. Debian's is. The one
actions/setup-python installs is not — 23 MB of libpython and 16 MB of
lib-dynload, which took the first Linux package CI ever produced to 20 MB
compressed against 8.0 MB for the identical commit built locally. Stripping in
the staging tree is what makes the two agree. Nothing outside the staging tree
is ever touched; the source of those copies is the machine's real Python
installation.
Three naming details that are easy to get wrong and fail silently:
- the stdlib zip must be
python314.zip, no dot — that is the only name CPython looks for onsys.path - ship
libSDL3.so.0(the SONAME), notlibSDL3.so.0.4.16 lib-dynload/must be shipped separately;.somodules cannot be imported from inside the zip
Scripting
scripts/clippy.py is the entry point. The full standard library is
available, plus anything you drop beside it.
The shipped one is a tech demonstrator for the drawing layer, not a personality: a paperclip composed from parts that blinks, sways, breathes, squashes and stretches, lifts its eyes and eyebrows, slides in, and cycles captions that fade. It exercises every piece described below, and it is meant to be replaced.
scripts/smoke_test.py is the non-interactive check — assertions over the host
API and the drawing layer, including that a parent's scale does not move or
resize its children, and that a hidden pet refuses to record:
./gobboclippy --script scripts/smoke_test.py # non-zero on any failure
The assistant
scripts/assistant.py is the one that listens. Press Ctrl+Alt+G from
anywhere and the pet comes out and starts recording; press it again and the
recording stops and the pet goes away. Double-clicking a pet already on screen
toggles the microphone without moving it. What it hears goes to a transcriber,
committed utterances go to an agent, and the answer is drawn on the window —
and spoken aloud, if a voice is configured.
./gobboclippy --script scripts/assistant.py
The chord does the whole gesture rather than only the microphone, because the case it exists for — you are working in another window and want to say one sentence — is the same case where the pet is hidden, and a hidden pet is not allowed to record: the visible pet is the recording indicator. Which of the two put the pet on screen is remembered, so a pet you had up anyway stays up when the recording stops, and a start that fails keeps the pet regardless — the message is written on its face, and hiding that would be the bug this behaviour replaced.
The chord is the one that matters in practice, and the double-click is the
fallback rather than the other way round: the pet never holds keyboard focus
and is usually behind whatever you are working in, so double-clicking it means
finding it first — the wrong amount of effort for saying one sentence. It is
the hotkey.toggle key in the config file below, null binds nothing, and
where the platform cannot grab a global key at all (Wayland) the pet says so in
the log and carries on with the double-click. See
Global hotkeys.
All three of those are subprocesses, because that is what they already are, and none of them is imported:
- the transcriber reads raw
s16le16 kHz mono on stdin and writes{"type": "ready"|"partial"|"final", ...}on stdout. That is the contract of tectum'sstreaming_stt_worker.pyverbatim, so that worker runs here unchanged — and a different engine, local or over the network, is a different program rather than a branch inscripts/gobbo/asr.py. - the agent is
tau --mode rpc, JSON-RPC 2.0 over stdio. - the synthesiser reads a line of text on stdin and writes raw
s16lemono on stdout, which ispiper --output-rawexactly. The host owns the playback device, so what comes back goes straight toclippy.speaker.write()with nothing in between — see Speaking.
Nothing heavy enters the pet's interpreter, and any of the three can be replaced by anything that speaks the same bytes.
Name the transcriber in config.json under clippy.pref_path(). There is no
default for it: a guess that happened to be wrong would be a microphone that
never answers rather than a sentence saying what to fix, so the first
double-click prints the file to write and the JSON to put in it.
{
"asr": {
"command": ["/path/to/asr-venv/bin/python",
"/path/to/tectum/tectum/audio/streaming_stt_worker.py"]
},
"tts": {
"command": ["/path/to/piper-venv/bin/piper",
"--model", "/path/to/voices/en_US-amy-medium.onnx",
"--output-raw"],
"rate": 22050
},
"hotkey": {
"toggle": "Ctrl+Alt+G"
}
}
The tts key is optional in a way asr is not. With no tts.command the
pet answers on screen and says nothing, which is what it did for its whole life
before this; the log says so once, and nothing appears on the pet's face,
because a voice nobody asked for is not a missing dependency. A tts.command
that is named and then will not run is a failure and is reported like the
agent's — on screen, with dictation and the agent still working.
tau does have a default — sys.executable -m tau_coding_agent.cli --mode rpc,
which names no path outside the tree because the package ships its own
interpreter and pip installs into that interpreter's site/. Override it,
and the model, under a "tau" key.
scripts/gobbo/accumulate.py is the seam between hearing and answering: the
agent is fed committed utterances, not a raw transcript stream. It is thin
today and exists for what goes there next — a wake word, a name resolver, an
end-of-utterance projection.
Pets
petdex.dev publishes several thousand animated pets as
plain HTTPS assets — a sprite sheet and a little metadata each, no API key and
nothing to run. scripts/pet_demo.py downloads one and puts it on the desktop:
./gobboclippy --script scripts/pet_demo.py # double-click to change emote
A sheet is a grid of 192×208 cells, eight to a row, one row per animation
state, which is exactly the atlas model clippy.Texture already has. So a
state's frames are a contiguous run of sprite_index and playing one is the
frame-sequence animation from the stage — there is no pet-shaped
code in the drawing layer, and none was added.
from gobbo import pet, petdex
petdex.search("otter") # the catalogue, cached after the first call
petdex.install("boba") # -> clippy.pref_path()/pets/boba/
p = pet.Pet("boba") # a clippy.Sprite, under p.sprite
p.play("waving") # loops
p.play_once("jumping") # once, then back to idle
No pet is bundled, and none ever will be. They are user-submitted fan art
and petdex claims no rights to the underlying IP, so this repository ships the
ability to read the format and the user downloads the art. assets/ stays ours.
The nine state names — idle, running-right, running-left, waving,
jumping, failed, waiting, running, review — are the same for every
pet, which is what makes them worth building on. They live in
scripts/gobbo/states.py, which imports nothing, because τ's interpreter needs
them too and has no clippy to import.
They are not shipped with a pet. petdex's pet.json carries a name, a
description and a file name, and says nothing about rows, frame counts or
timing; that table is transcribed from petdex's own site source. The frame
counts matter: rows are padded to eight cells with transparent frames, so a
four-frame wave played across all eight columns spends half its loop invisible.
See docs/petdex.md for the format in full.
Reading WebP is why libwebp is linked — 97% of the corpus is WebP and stb_image
decodes none of it. --capabilities reports which decoders a build has.
Settings
The tray's Configure... asks the script, rather than doing anything itself:
it fires the configure hook, and a script with no handler gets a line in the
log saying so rather than a menu entry that silently does nothing. Both shipped
scripts register one.
The host draws the window and owns OK / Cancel / Apply. It does not know what
any setting is — it renders a list of field dicts and hands the edited values
back, so adding a setting is a dict in scripts/gobbo/settings.py and no change
to the C++ at all.
clippy.settings_open({
"title": "gobboclippy settings",
"fields": [
{"key": "window.x", "label": "Position X", "tab": "Window",
"type": "int", "value": 1612, "min": -32768, "max": 32768,
"live": True, "help": "Pixels from the left edge."},
],
"on_change": lambda key, value: ..., # a live field was edited
"on_apply": lambda values: None, # None, or a message to show
"on_cancel": lambda: ..., # put back what on_change did
})
type is int, range, text, bool, choice or hotkey.
A range is an int that has to have bounds, drawn as a slider with the
number in a box beside it — the slider is the coarse control, the box is the
exact one, and the mouse wheel over either is the fine one, one unit per notch
and ten with Shift. The wheel is claimed for the hovered field, so the list
underneath does not scroll out from under the value you were aiming at. A range
without min and max, or with min >= max, is refused: a slider needs two
ends, and unbounded it would silently become a plain box. Plain int stays a
box with steppers, because plenty of bounded integers — a port, a sample rate —
would be absurd as a slider.
A choice carries choices and crosses the boundary as the chosen string,
never as an index, so reordering the list cannot silently change what a config
file means. An unknown type is refused at the door rather than drawn as
something else.
A hotkey is a chord with Set and Clear beside it. Press Set and
the field listens; press the chord and it holds it, spelled the host's way. Its
value is "" for nothing bound, and a value that would not bind is refused
with the schema rather than shown as the current setting — so a hotkey field
always displays something that works.
It is a kind of its own rather than a text box you type Ctrl+Alt+G into,
because pressing the chord is the only way to find out whether you can press
it: half the plausible ones are already taken by the desktop, and a box you
type into cannot tell you that. Three things fall out of capture that are worth
knowing:
- The chord you already have is capturable. Capture releases the current grab for as long as it is listening and takes it back after. Without that, the one chord guaranteed not to work would be the one already in the box — a grabbed chord is not delivered to the focused window at all, so pressing it would have triggered the pet instead of being captured.
- A chord you cannot capture is a chord you cannot bind. If the desktop
owns
Super+L, pressing it during capture does nothing, because the desktop took it before this program could see it. That is the same reasonbind()would refuse it, so the field quietly filters itself down to chords that would actually work. - A refused chord keeps the field listening, with the reason under it. You pressed something; being told why it will not do and then having to click Set again would be a dialog arguing rather than helping.
Capture needs no global grab of its own and works even where
clippy.hotkey.available() is false — the dialog has keyboard focus, which is
exactly what the pet never has. On such a desktop the chord is saved and simply
not active, and the field's help text says so.
clippy.hotkey.parse(chord) is the matching primitive: it returns the host's
spelling or raises ValueError, grabbing nothing. That is what lets
gobbo/settings.py check a chord out of the config file and put the complaint
in its own help text — the alternative was handing a hand-edited typo to
settings_open() and being refused the window that exists to fix it.
settings_set() moves a field the user did not touch, which is what a linked
pair needs. It deliberately does not fire on_change — the host was told
this value, nobody edited it — and without that distinction a width adjusting
a height adjusting a width would not terminate. It still counts towards dirty,
because it is still a change someone will want saved.
on_apply returns None on success, or a string to show in the dialog without
closing it; a handler that raises is the same answer with the exception for its
text, and the traceback goes to stderr. A failed write must never look like a
successful one. It is called only when something actually changed, so OK and
Apply write nothing when nothing was edited, and Apply is disabled until
there is something to apply.
scripts/gobbo/settings.py is the policy half. It re-reads the config file
every time the window opens — the dialog is a view of what is on disk, not of
what this process last remembered — and on apply it re-reads again, replaces
only the keys its sections own, and writes through a temporary file in the same
directory. A transcriber command this dialog has never heard of survives it.
A section is the unit of extension: a class that says which fields it
contributes, what a live edit does right now, whether the values can be made to
work, and how to fold them back into the config. WindowSection is the first
one, and writes:
{ "window": { "x": 1612, "y": 580, "width": 300, "height": 380,
"fixed_ratio": false } }
Position runs from 0 to the far edge of the work area, so the top of the slider
really does park the pet entirely off the screen — that is a position somebody
may want, and a slider stopping at "still fully visible" would be deciding
otherwise for them. The cost is that a pet on a monitor to the left of the
primary needs a negative x and the slider does not go there; that case wants a
display picker rather than a wider slider. Size runs 64–512, deliberately
narrower than --size's 32–2048, because a slider spanning the wider range
would put every useful size a pixel apart.
Fixed ratio locks width against height at whatever proportion they are in
when it is ticked. Dragging either moves the other, through settings_set().
At the end of a scale the pair stops rather than the follower running off it —
the proportion is not held there, but it is not forgotten either, so coming back
off the stop restores it. The follower is what gets clamped, never the slider
being dragged: writing back into a slider the user is holding would leave the
window one size and the dialog showing another, because a slider rewrites itself
from the mouse every frame.
Both shipped scripts apply that at startup and fall back to their own placement when the file says nothing — a default for somebody who has never expressed a preference, which is not the same as a fallback around an error. A config file that exists and does not parse still raises.
HotkeySection is the second, and is not in SECTIONS by default —
scripts/assistant.py registers it with settings.add_section(). It is the
only tab whose absence is a feature: scripts/clippy.py opens the same window
and has no microphone, so a chord field there would be a setting that does
nothing, which is worse than a missing tab. It writes {"hotkey": {"toggle": "Ctrl+Alt+G"}}, with null for nothing bound.
Its apply is why sections have that verb at all. It takes the chord before
the file is written, so a chord another application owns comes back as a
message in the dialog and nothing is saved — the failure worth avoiding being a
config file that records a shortcut which has never worked, since the file is
what the next launch believes. A section that returns a message stops the write
for every section, which is the right coupling: the file is written once, so it
is either written correctly or not written.
The geometry preview
A window position you cannot see is a number you are guessing at, so editing one puts the pet on screen. That is not the same as showing it, and the difference is a third state:
| pet window | visible() |
previewing() |
|
|---|---|---|---|
| hidden | unmapped | False |
False |
| geometry edited while hidden | mapped | False |
True |
| Show pressed | mapped | True |
False |
| Hide during the session | mapped | False |
True |
| dialog closed | follows visible() |
False |
While previewing, the dialog carries a banner above the buttons saying the pet will go back to hidden when the dialog closes, with a Show button that makes it mean what it looks like it means. The tray's Hide becomes Hide (preview is on), because during a settings session with geometry in play it demotes the window to a preview rather than taking it off the screen.
This is deliberately independent of OK / Cancel. Cancel puts the geometry back; it does not un-show a pet the user pressed Show on. Repositioning a hidden pet, showing it, and then cancelling leaves it shown, where it was before.
The control channel
A script can let other processes drive the pet. One JSON object per line over a socket, the same shape as the transcriber and the agent:
./gobboclippy --script scripts/pet_demo.py &
./gobboclippy --python scripts/pet_ctl.py play waving
./gobboclippy --python scripts/pet_ctl.py state
./gobboclippy --python scripts/pet_ctl.py say "back in a minute"
The verbs are the script's, not the channel's. scripts/gobbo/control.py
owns the transport and knows nothing about pets; pet_demo.py registers play,
say, state, show and hide. An unknown verb answers with the list of ones
that host does serve.
A request never runs on the socket thread. It goes onto the same queue the transcriber and the agent already use, the frame hook runs it, and the socket thread waits for the answer — so the rule that only the frame hook touches the drawing layer holds for every verb without a script having to think about it.
The endpoint is control.json in clippy.pref_path(). On POSIX it names a unix
socket at mode 0600, and the filesystem is the access control. On Windows
CPython exposes no AF_UNIX, so it is a loopback TCP port guarded by a random
token — any local process can reach a loopback port, which is what the token is
for. Clients read the file either way, so client code is one path on both.
The pet, from an agent
scripts/gobbo/gobbopet.py is a τ
extension. It runs inside τ's interpreter, not this one, and reaches the pet
through the control channel like any other client:
tau -e scripts/gobbo/gobbopet.py \
--ext-config gobbopet.endpoint="$HOME/.local/share/gobboclippy/control.json"
It registers two halves that are deliberately independent, because which one you want is a policy question the extension refuses to answer:
- tools —
pet_play,pet_show,pet_hide,pet_say,pet_info— so a model can move the pet on purpose, with a closed enum of the nine states; - a turn-end emote, so the pet reacts with no model involvement at all.
scripts/gobbo/emote.pyclassifies what the turn did — tools errored, files written, how many round trips — rather than reading the prose for a mood.
Both are on by default and they do not fight: the emote stands down when the
model already moved the pet that turn, which it detects by looking for its own
tool names in the transcript rather than by keeping a flag there is no right
moment to clear. --ext-config gobbopet.mode=tools|emote|both picks.
The hook is user_turn_end, which fires once per utterance — turn_end fires
once per LLM completion, so an answer that took six tool round trips would emote
six times.
pet_info hands the model the pet's own description fenced and labelled as
untrusted: those are stranger-written strings from a public gallery, and they
are data rather than instructions.
The interpreter
The same binary is also a Python interpreter, and the package ships it under the name one expects:
./python3 -m pip install ffwf-tau-agent-core # into site/, no --target needed
./python3 -c 'import tau_agent_core' # the same runtime the pet uses
./gobboclippy --python -m pip list # identical; --python must come first
./python3 # the REPL
python3 is a symlink to gobboclippy (python.exe is a copy, on Windows).
The dispatch is on argv[0], so it is one code path with --python; the
alias exists so that sys.executable names something a subprocess can run as
python — pip's build isolation, multiprocessing, anything that spawns
[sys.executable, "-c", ...]. Nothing in this mode touches SDL video: a pip
install runs on a machine with no display.
clippy is importable here, and has no window behind it. That is what
makes scripts/pet_ctl.py possible: a client needs clippy.pref_path() to
find the running pet's socket, and computing that path any other way means
hardcoding a platform convention. Everything that needs a window —
set_size, show, Texture, Font.measure — raises and says to use
--script instead. Running a pet script under --python is the easy mistake,
so the error names the fix rather than guessing at a startup race.
What pip install puts in site/ is what --script can import. That works
because sys.prefix is site/ and sys.base_prefix is the package root —
CPython's own model of a venv — and it is done that way for a reason beyond
tidiness. Debian's CPython patches sysconfig to answer
local/lib/python3.X/dist-packages whenever the two prefixes agree, and
vanilla CPython answers lib/python3.X/site-packages; a package built on the
Forgejo runner and one built on GitHub would otherwise install to different
places, only one of them on sys.path. The GitHub Linux job installs Tau's
core from PyPI for real and then imports it from the pet, so the layout is
checked rather than assumed.
.forgejo/workflows/build.yml is written to be the other half of that check —
it builds in a Debian container against whatever python3-dev that image has,
which is the patched interpreter that would have got the path wrong, and it is
the only place the mingw cross-compile is run under wine. It does not run.
That Forgejo instance has no runner able to do this work, so every execution
since the workflow was added has failed during setup, inside a minute. The
workflow is kept because it is correct and costs nothing to keep, but nothing
in this file is verified by it: GitHub is the CI that checks things today, and
the Debian interpreter and the wine test are done by hand or not at all.
The runtime ignores PYTHONPATH, PYTHONHOME and the user site, as
python -E -s does. It is self-contained by construction, and the host's
packages were built for a different interpreter. pip ships as its own wheel
on sys.path — the same one the bundled interpreter would have put in a venv,
resolved through ensurepip at configure time — so it is whatever pip that
CPython release carries, not a pinned version of this project's.
The host
import clippy
caps = clippy.capabilities() # dict; what the platform actually granted
clippy.set_sprite("clippy.png") # bare names resolve against assets/
clippy.set_position(x, y)
clippy.on("show", lambda: clippy.log("shown"))
clippy.show()
| call | effect |
|---|---|
show() / hide() / toggle() |
window visibility |
visible() |
bool |
position() / set_position(x, y) |
window position |
size() / set_size(w, h) |
window size; the stage follows |
display_bounds() |
(x, y, w, h) work area of the display the pet is on |
set_sprite(path) |
load one PNG, contained and centred; OSError if it cannot |
capabilities() |
the dict behind --capabilities |
on(event, fn) |
show, hide, quit, frame, click, double_click, mic, hotkey, hotkey_release, configure |
previewing() |
bool; on screen only to demonstrate a setting |
settings_open(spec) |
open the settings window on a field schema |
settings_is_open() / settings_close() |
is it up; close it |
settings_get(key) / settings_set(key, v) |
read or move a field of the open dialog |
preview() |
put the pet on screen to demonstrate a setting |
pref_path() |
per-user directory for this application's own files, created |
quit() |
shut down |
log(msg) |
write to the SDL log |
| hook | arguments |
|---|---|
show / hide / quit |
none |
frame |
seconds since the last frame |
click |
(x, y, button, clicks) |
double_click |
(x, y, button) |
mic |
True when recording started, False when it stopped |
hotkey |
the bound chord, as a string |
hotkey_release |
the same, when the key comes back up; never on Windows |
configure |
none; the tray's "Configure..." was chosen |
The window swallows clicks over its whole area, transparent corners included,
so a click event is "the pet was clicked" and needs no hit test. SDL counts the
clicks, so there is no double-click timer here — but a double-click also fires
click twice, with clicks 1 then 2, as every toolkit does it. Use one hook
or the other.
pref_path() is where a script keeps its own files: ~/.local/share/ gobboclippy on Linux, %APPDATA% on Windows, ~/Library/Application Support
on macOS. A script that needs a settings file asks for this rather than
assembling a path out of $HOME, which is what keeps the tree forkable.
Visibility changes route through one path (App::setVisible) whatever
triggers them — tray, script, or window manager — so hooks fire on the
transition only, and a hook that calls show() cannot recurse.
visible() and previewing() are two different questions, and both have true
answers at once. A settings window demonstrating where the pet will sit needs it
on screen; the user has not asked to see it. So the window is mapped,
visible() is False, the show hook has not fired, and mic.start() still
refuses — because the visible pet is the recording indicator, and a pet the user
believes is hidden must not be recording. preview() is how a settings handler
asks for that state, and it is refused outside a settings session: closing the
dialog is the only thing that ends one.
on() rejects an unknown event name rather than registering a hook that would
never fire. The frame hook is called with the seconds since the last frame —
the same number the animations are ticked with, so script timing and animation
timing cannot drift apart.
set_sprite() is the one-image shortcut. Anything composed goes on the stage.
The microphone
clippy.mic.spec() # always (16000, 1, "s16le")
clippy.mic.devices() # [(id, name), ...]
clippy.mic.start() # or start(device_id)
clippy.mic.read() # bytes; b"" when nothing is waiting
clippy.mic.queued() # bytes waiting
clippy.mic.active()
clippy.mic.stop()
The host owns the device; the script owns where the audio goes. There is no
new dependency behind this — SDL3 already does capture, resampling and
buffering, so src/Mic.cpp is one SDL_AudioStream and a mutex.
The format is fixed rather than negotiated. 16 kHz mono s16le is what every
consumer downstream wants — it is the stdin contract of tectum's streaming
worker verbatim, and what whisper, silero and the small edge models expect —
and SDL resamples from whatever the hardware actually offers, so pinning it
costs nothing and removes a negotiation from every caller.
read() is safe from a thread, which is the point: the event loop gives up
the GIL for the whole of each iteration and takes it back only to enter Python,
so a script's audio thread actually runs instead of getting a sliver of each
frame.
Recording only happens while the pet is visible. Hiding the window closes
the device, and start() while hidden raises. That rule is in the host rather
than in the script because a user relies on it to know when the microphone is
live, and a script is not the right place to keep a promise made to somebody
else. Closing rather than pausing is deliberate too: a paused device keeps the
operating system's own microphone-in-use indicator lit, and the whole point is
that that indicator and the pet agree.
SDL_INIT_AUDIO is not part of startup. A machine with no audio stack runs
the pet perfectly well; --capabilities reports microphone: no with the
reason, and only start() has grounds to complain.
devices() is empty both when nothing is plugged in and when there is no audio
stack at all, because to a caller asking what it can record from those are the
same answer. The difference is in the --capabilities note, and in what
start() raises.
The reverse does not hold, and it is worth saying plainly: microphone: yes
means a device was enumerated, not that it can be opened. A headless Linux
machine with ALSA installed advertises a default device with no sound card
behind it. Finding out for certain would mean opening the device, which is the
one thing this program must not do behind the user's back — so start() is
where that answer arrives, and it arrives as SDL's own error text.
Speaking
clippy.speaker.spec() # start()'s defaults: (22050, 1, "s16le")
clippy.speaker.devices() # [(id, name), ...]
clippy.speaker.start() # or start(rate=16000, channels=1, device=id)
clippy.speaker.write(data) # raw s16le at the rate given to start()
clippy.speaker.queued() # bytes written but not played yet
clippy.speaker.drained() # open, and everything written has played
clippy.speaker.active()
clippy.speaker.stop() # closes, dropping anything unplayed
The microphone's mirror, down to the mutex: src/Speaker.cpp is one
SDL_AudioStream, and write() releases the GIL for the same reason read()
does. Two things are deliberately not mirrored, and both are the
microphone's own reasoning run backwards.
The format is not pinned. The rate is a property of the voice the
synthesiser loaded — piper's medium voices are 22050 Hz, its low ones 16000 —
so it is an argument to start(). SDL converts to whatever the device wants,
which is why handing it the voice's own rate costs nothing; asking the script
to resample first would be asking it to do SDL's job badly.
A hidden pet may speak. clippy.mic.start() refuses while the pet is
hidden because the visible pet is the recording indicator, and a microphone
that might be open unannounced is a privacy question. A sound announces itself:
nobody has to be shown that the pet is talking. So there is no visibility
rule here, and that is what completes the chord's gesture — summon, ask,
dismiss, and the answer arrives out loud with nothing left on screen to read.
Which is also why the pet does not decide what to say aloud in the host. The
gate lives in scripts/gobbo/tts.py: an answer that is empty, over
tts.max_chars (320 by default, about twenty seconds), or built out of things
a listener cannot hear — a code block, a heading, a list, a URL, more than one
paragraph — is refused rather than truncated, with the reason on the status
line. The answer is on the pet's face either way, so a refusal costs nothing
that was there; reading half a sentence aloud as though it were whole is the
same lie the screen already declines to tell. "gate": false turns it off.
Opening the microphone silences the pet mid-sentence. That is the echo problem solved by construction rather than by a half-duplex timer: the two devices are never open in the same direction at once, so the room microphone cannot hear the pet and return its own words as a prompt. It is also just what interrupting someone means.
Interrupting kills the synthesiser, and that is not heavy-handedness. A raw PCM stream has no utterance boundary in it, so there is nothing in the bytes that says where the abandoned reply ends — and inferring it from a gap in arriving audio does not work, because a synthesiser emits one sentence per burst and the gaps inside a reply are the same as the gaps between replies. Measured, when this was a gap heuristic: five seconds after an interrupt, 546 KB — twelve seconds of speech — was still queued and playing. A kill cannot be wrong, and the replacement worker reloads its voice during the seconds the user spends saying the thing they interrupted for. A worker that has not been asked to speak is left alone, so interrupting a silent pet costs nothing.
Global hotkeys
clippy.hotkey.available() # can this platform grab at all
clippy.hotkey.delivers_release() # will 'hotkey_release' ever fire
clippy.hotkey.bind("Ctrl+Alt+G") # -> "Ctrl+Alt+G"; replaces any previous
clippy.hotkey.bound() # the bound chord, or None
clippy.hotkey.unbind()
clippy.hotkey.parse("ctrl+alt+g") # -> "Ctrl+Alt+G"; validates, grabs nothing
clippy.on("hotkey", lambda chord: ...)
clippy.on("hotkey_release", lambda chord: ...)
A chord that reaches the pet while you are working in something else. This is
the only input path in the program that does not go through the window, and it
exists because the window is the problem: the pet is borderless and
SDL_WINDOW_UTILITY, it never holds keyboard focus, and the window manager is
right not to give it any — so SDL's keyboard events describe only the moments
when the user had clicked on the pet, which are exactly the moments they did
not need a shortcut.
There is no portable way to do this. Each platform is a separate API with its
own opinions, in src/platform/, and src/Hotkey.h is where the comparison is
written down:
| mechanism | press | release | |
|---|---|---|---|
| X11 | XGrabKey on the root window, read back through SDL_SetX11EventHook |
yes | yes |
| macOS | Carbon RegisterEventHotKey and one application event handler |
yes | yes |
| Windows | RegisterHotKey, read back through SDL_SetWindowsMessageHook |
yes | no |
| Wayland | — | no | no |
Modifier names are Ctrl, Alt, Shift and Super, case-insensitive, in
any order, with Control, Option, Opt, Win, Cmd, Command and Meta
accepted as aliases. Key names are SDL's — SDL_GetKeyFromName's
vocabulary rather than a second list of our own to get out of step with it — so
a letter or digit, F1 through F24, Space, Tab, Escape, Return,
Backspace, Insert, Delete, Home, End, PageUp, PageDown, or an
arrow. bind() answers with the chord as the host spells it, so "alt + CTRL+g"
comes back "Ctrl+Alt+G" and there is one spelling of a chord rather than as
many as there are config files.
A chord needs Ctrl, Alt or Super in it. A global grab takes the key from
every other application on the desktop, so binding G would mean you had
stopped being able to type g anywhere — and Shift+G is the same trick with a
capital letter. There is no way to offer that which is not a trap, so it is
refused rather than offered with a warning.
The two failures are different exceptions, because only one of them is
fixed by editing the string. ValueError means the chord is wrong: it does not
parse, the key is not a name, there is no real modifier. RuntimeError means
the chord is fine and cannot be had: something else already owns it, this
keyboard has no such key, or the platform cannot grab. That second case is the
one worth the machinery — X11 reports a refused grab asynchronously, so the
default behaviour is that XGrabKey appears to work and the key is simply
never delivered. src/platform/HotkeyX11.cpp brackets the grab with an error
handler and an XSync to turn that into a failure at bind time, naming the
chord that is already taken.
Two smaller things, both invisible until they are wrong:
Lock modifiers are part of an X11 grab's mask, so the chord is grabbed four times — plain, with CapsLock, with NumLock, with both. Without that it works perfectly until somebody's NumLock is on.
A release is matched on the key alone. People let go of Ctrl and Alt first and the letter last, so by the time the key comes up the modifiers are usually already gone. Matching the release against the full chord would reject most real ones, which for a toggle means the next press looks like a repeat and gets swallowed.
Only one chord is bound at a time, matching clippy.on()'s one hook per event.
The hook is handed the chord anyway, so that binding several later adds an
argument rather than changing a signature already in use.
bind() and unbind() are main-thread only and say so when they are not.
clippy.mic.read() is safe from a worker thread and this deliberately is not:
a lock here would invert against the one Xlib takes for itself — the main
thread holds the display lock inside XNextEvent and then calls our hook,
which wants the queue, while a second thread holding the queue waits on the
display. Binding a chord is setup rather than work, so refusing off-thread
loses nothing; a worker that wants to rebind hands the chord to the frame
hook. parse() has no such rule, because it asks nothing of the platform.
The settings window can capture a chord by listening for it — see
Settings for the hotkey field type, which is where the
interesting constraint lives: a grabbed chord is not delivered to the focused
window, so capture has to release the current grab while it listens.
The stage
clippy.stage is a live list of top-level drawables. A drawable's .children
is the same kind of list. Both index, iterate, and support append, remove,
clear and in.
import clippy
body = clippy.Sprite(texture=clippy.Texture("clip_body.png"),
origin=(128, 226), # pivot: bottom of the clip
align=clippy.Align.TOP_CENTER, margin=30)
clippy.stage.append(body)
eye = clippy.Sprite(texture=clippy.Texture("eyes.png", 48, 48), # 5-frame strip
pos=(-10, -108), origin=(24, 24), parent=body)
eye.animate("sprite_index", [0, 1, 2, 3, 4, 3, 2, 1, 0], 0.34) # blink
body.animate("scale_y", 1.22, 0.9, easing=clippy.Easing.EASE_OUT_ELASTIC)
pos is the pivot: the point origin of the content sits there, and rotation
and scale happen about it. Children inherit their parent's translation and
opacity, never its scale or rotation — which is what lets the paperclip
stretch without distorting the eyes, and lets one opacity animation on the
root fade the whole character.
| type | |
|---|---|
Texture(path, sprite_width=0, sprite_height=0, smooth=True) |
a PNG, optionally sliced into a grid of frames; 0 means one frame |
Font(path) |
a TrueType face; ASCII 32–126, rasterised on demand per size |
Sprite(texture=, sprite_index=, ...) |
one frame of a texture |
Caption(text=, font=, font_size=, fill_color=, ...) |
text; \n starts a line |
Animation |
the handle animate() returns |
Every drawable carries pos, x, y, origin, scale (per-axis; negative
mirrors), rotation, opacity, visible, z_index, name, parent,
children, bounds, global_bounds, subtree_bounds, align, margin, and
the methods animate(), realign(), move(), remove().
d.animate(property, target, duration,
easing=None, delta=False, loop=False,
callback=None, conflict_mode="replace")
targetmay be a number, a list of ints (a frame sequence, stepped not interpolated), a 2-tuple (point), a 3/4-tuple (colour) or astr(typewriter reveal).deltamakes the target relative to wherever the property started.easingis aclippy.Easingmember — 36 curves, including thePING_PONG_*family, which return to their start so aloop=Trueanimation has no seam.callback(drawable, property, final_value)fires on completion.conflict_modedecides what a second animation on the same property does:"replace"(default),"queue", or"error".
A misspelled property, easing or alignment raises rather than running to completion having changed nothing.
align is a clippy.Align member — the nine corners, sides and centre —
measured against the parent's bounds, or the window's for a top-level drawable.
It is applied when set and on realign(), not continuously: a caption whose
text changed has to be realigned.
Effects
| property | |
|---|---|
glow |
halo blur radius in px; 0 disables |
glow_color |
halo colour; its alpha sets how opaque the halo is |
glow_strength |
brightness; above 1 adds additive passes |
glow_hardness |
0 broad soft falloff … 1 near-solid outline |
glow_over |
draw the halo above the subtree instead of below (mode) |
glow_flat |
halo takes its colour from glow_color alone (mode) |
aberration |
colour-channel separation in px; 0 disables |
aberration_angle |
direction of that separation, in degrees |
The quantities are ordinary animatable properties, so nothing about them is special-cased:
body.glow = 18.0
body.glow_color = (120, 190, 255)
body.glow_flat = True
body.glow_hardness = 0.45
body.animate("glow_strength", 0.5, 3.1,
easing=clippy.Easing.PING_PONG_EASE_IN_OUT, delta=True, loop=True)
glow_over and glow_flat are modes, not quantities — there is no halfway
point between a halo above the character and below it — so they are plain bools
and animate() rejects them rather than interpolating something meaningless.
glow_flat is what makes glow_color mean what it says. Without it the
halo is a blurred copy of the art and glow_color only tints it, so a dark
character glows dark and no amount of glow_strength changes the hue. With it,
a white silhouette of the subtree is blurred instead — costing one extra walk of
the subtree, which is why it is opt-in.
glow_over wants a low glow_color alpha. Drawn above the character an
opaque halo veils it; around 60–90 it reads as a bloom with the face still
legible.
An effect applies to the whole subtree, not to the drawable alone. Setting
glow on the clip haloes the character's silhouette once, rather than putting
three haloes around the clip, an eye and a brow; setting aberration on it
makes the parts fringe with each other instead of against each other. A
drawable with an effect renders its subtree into a private target and
composites that.
Two consequences worth knowing:
- A glow spills past the character and the window clips it. Nothing resizes
the window to compensate — a pet that silently grew its own window would be
harder to reason about than one whose halo stops at the edge.
subtree_boundsis there to work out the headroom you need. glowis what the effect's render target is sized from, so animating the radius resizes that target while animatingglow_strengthorglow_colordoes not. The target size is rounded to a multiple of 16 so a radius animation is not a texture allocation per frame, but brightness is still the cheaper knob for a loop.
No shaders. Both effects are multi-pass composites built from the blend modes
every SDL renderer implements, including the software one — see src/Effects.h
for why that constraint was worth keeping, and tests/fx_pixels.cpp for what
holds it in place. The blur downsamples by repeated halving rather than in one
step: SDL_SCALEMODE_LINEAR reads four texels however far it is minifying and
there are no mipmaps, so a single large reduction aliases instead of blurring,
and the halo crawls when the character moves.
Assets
assets/*.svg are the source of truth; the PNGs are generated. Each SVG is
authored at its final pixel size — a sprite strip is as wide as its frames, not
square — so the renderer reads width and height from the file.
python tools/render_assets.py # 1:1; pass a factor for a HiDPI variant
Uses whichever rasteriser is installed (rsvg-convert, inkscape or ImageMagick) and reports which one. It errors rather than emitting a placeholder if none is available.
clippy.svg is the whole character, and is the tray icon. It is also broken
into the parts the demo composes — clip_body.svg, eyes.svg (a 5-frame blink
strip), brow.svg (one brow; the other is the same sprite with scale_x = -1)
— all drawn in the same 256×256 frame, so the offsets in scripts/clippy.py
can be read straight off clippy.svg.
assets/JetBrainsMono.ttf is shipped. It is JetBrains Mono 1.0.3 under
Apache-2.0, the same font and version McRogueFace redistributes; its notice is
beside it and in the package's licenses/.
Layout
| path | |
|---|---|
src/main.cpp |
CLI, init order, event loop, and the interpreter mode |
src/PetWindow.* |
SDL3 window flags, render, the one-image shortcut |
src/Tray.* |
SDL_Tray menu: Show / Hide / Configure... / Exit |
src/Settings.* |
the settings window: Dear ImGui over a schema it does not understand |
src/Capabilities.* |
what the platform granted, and why not |
src/AppPaths.* |
exe-relative path resolution |
src/Mic.* |
the recording device: one SDL_AudioStream, fixed at 16 kHz mono |
src/Speaker.* |
the playback device: its mirror, at the voice's own rate |
src/Hotkey.* |
global chords: parsing, the queue, and the three platforms compared |
src/platform/Hotkey*.cpp |
one per platform — XGrabKey, RegisterHotKey, RegisterEventHotKey, and a stub that refuses |
src/PyClippy.* |
the clippy extension module: host calls, clippy.mic, clippy.speaker and clippy.hotkey |
src/Drawable.* |
transform, tree, alignment, the property system, Stage |
src/Sprite.* src/Caption.* |
the two drawables |
src/Texture.* src/Font.* |
PNG and WebP atlases (stb_image, libwebp), glyph atlases (stb_truetype) |
src/Animation.* src/Easing.* |
the animation manager and 36 curves |
src/Effects.* |
the glow and aberration composites, and why they use no custom blend mode |
tests/fx_pixels.cpp |
pixel-level check of those composites (-DGC_BUILD_TESTS=ON) |
src/PyDraw.* |
Python types for all of the above — the only file here that includes Python.h |
scripts/assistant.py |
the listening pet: mic, transcriber, agent, captions |
scripts/pet_demo.py |
a downloaded petdex pet, and the control channel |
scripts/pet_ctl.py |
the control channel's shell head |
scripts/gobbo/ |
their parts — config, asr, accumulate, tau, petdex, pet, states, control, emote, gobbopet; stdlib only |
tests/test_emote.py |
the emote classifier, under a bare python3 |
tests/test_gobbopet.py |
the τ extension, against a running pet |
cmake/Package.cmake |
the zip-and-ship staging tree |
cmake/ZipStdlib.cmake |
stdlib zip construction |
cmake/StripTree.cmake |
strip the staged binaries (Linux, Windows) |
cmake/MacFinalize.cmake |
macOS: relocate, strip, sign, seal the bundle — in that order |
cmake/Info.plist.in |
macOS: the bundle's identity, and the microphone prompt |
cmake/MacIcon.cmake |
macOS: assets/clippy.png → .icns |
Status
Linux/X11 — working. Build, capability probe, transparency, always-on-top, tray icon and menu, relocatable package, and the smoke test run from an extracted tarball with a scrubbed environment on the bundled interpreter. The drawing layer — composed sprites, frame-sequence blinking, per-axis squash and stretch, eased motion, parented eyes and eyebrows, alignment, and faded text in the shipped font — is verified on screen and by the smoke test.
The effect compositor is verified further down than that: tests/fx_pixels.cpp
reads pixels back and asserts on them, and CI runs it twice, once on whatever
renderer SDL picks and once with SDL_RENDER_DRIVER=software. The halo is only
worth anything if it carries alpha, and that is a property of the blend modes
used rather than of anything visible in a screenshot of an opaque window.
The microphone — working on Linux; unverified elsewhere. Device enumeration,
the fixed 16 kHz mono capture format, the hidden-pet refusal and the
close-on-hide rule are all in the smoke test, and hold on a machine with no
audio stack at all — which is every CI runner, so what CI proves is the
refusals, not capture. The capture path itself is verified locally: SDL
delivers the right byte count at the right rate, and scripts/gobbo/asr.py
raises a silent event when an open device delivers nothing but zeroes, which
is what a muted input looks like from in here and is otherwise
indistinguishable from a transcriber that has stopped working.
The global hotkey — working on Linux/X11; the grab is checked everywhere, the press is not. Every part of it up to the keypress is in the smoke test — parsing, normalising, the refusals, the grab, rebinding, unbinding — so CI checks those on all three platforms. The press itself cannot be checked there and this is not a gap that can be closed: a global grab is global, so exercising it needs a focused desktop with a keyboard on it, and no runner has one.
Locally the whole path is verified with synthesised keys through xdotool,
which go through the X server exactly as typed ones do: repeated taps, a key
held down for a second arriving as one press and one release rather than a
stream of repeats, the modifiers released before the key, and the chord still
firing with CapsLock and with NumLock engaged. The already-owned case is
verified by running two pets and having the second one refuse by name. The
Windows and macOS backends compile in CI and bind there; nobody has pressed the
key. For macOS the two things to check first are that kEventHotKeyReleased
actually arrives and that no permission dialog appears — Carbon's
RegisterEventHotKey should need neither Accessibility nor a TCC prompt, which
is the whole reason it was chosen over a CGEventTap.
The settings window's chord capture is verified the same way, by driving the
real dialog with synthesised clicks and keys: Set starts it listening, the
chord that is already grabbed is captured rather than triggering the pet, a
chord with no real modifier is refused with the reason and the field keeps
listening, Escape cancels without changing the value, Clear empties it, and the
borrowed grab is back afterwards — checked by pressing it and watching the pet
respond again. Saving through HotkeySection writes the chord, binds the new
one and releases the old one. What CI covers is the field type rather than the
capture: the schema, the spelling it hands back, its refusal of an unbindable
value, and that settings_set() cannot write into it.
Pets and the control channel — working on Linux; the format is checked everywhere. The WebP decoder, and that an undecodable one raises rather than returning a placeholder, are in the smoke test, so CI checks them on all three platforms. What CI cannot check is anything past that: installing a pet needs the network and driving one needs a window, and runners have neither.
Locally the whole path runs — catalogue, install, the nine states on screen,
the socket, pet_ctl.py from a shell, and the τ extension's two halves against
a live pet via tests/test_gobbopet.py. tests/test_emote.py is the piece
with the judgement in it and runs under a bare python3 anywhere, because the
classifier is a pure function of the two arguments τ hands its hook.
The Windows and macOS control channel is unverified. On Windows it is a
different transport — loopback TCP and a token, because CPython exposes no
AF_UNIX there — and that branch has never run.
macOS needed packaging work before it could listen at all: it grants microphone
access per bundle, through a TCC prompt driven by
NSMicrophoneUsageDescription in an Info.plist, and a bare executable has
nowhere to put that string. The macOS package is now a .dmg holding a signed
.app — see docs/macos.md. CI checks the bundle is shaped to be able to ask;
no runner has an input device or a person to answer, so the prompt itself is
still unverified.
Windows — working. Built natively by CI on a hosted Windows runner, where
the packaged zip is extracted and runs the full smoke test — the drawing layer
included — on its bundled Python: window, tray, transparency, always-on-top,
textures, the tree, animation and text, all reporting windows as the video
driver. That is the authoritative check, and v0.4.0 passed it.
Locally the same tree cross-compiles from Debian with mingw-w64 and runs under
wine, which is a convenience rather than proof — wine is not Windows — but it
does exercise the bundled interpreter, the stdlib zip and the .pyd extension
modules rather than only checking that the binary links. Use wine 10 or
newer: wine 8.0 hands a piped process invalid standard handles, which stops
CPython from starting at all, and the stock python.org python.exe fails there
identically. docs/cross-compile.md has the details, along with the C runtime
rules that the mingw/MSVC split imposes on src/main.cpp.
macOS — working in CI, not buildable here. Hosted arm64 runners build,
relocate, sign and smoke-test the package, which is a .dmg containing an
ad-hoc signed .app; CI mounts the image and re-verifies the bundle's seal on
the other side. Nothing about it can be reproduced on this machine, so every
macOS change is verified only after it is pushed, and nothing that needs a
person in front of a screen is verified at all. See docs/macos.md for the
routes, what signing at each level buys, and why a microphone makes the $99
Developer ID a different question than Gatekeeper does.
Wayland — untested, and expected to be partly broken. SDL's Wayland
backend has no SetWindowAlwaysOnTop hook at all, and SDL_SetWindowAlwaysOnTop
returns success regardless, so the flag reads back as set while nothing has
happened. src/Capabilities.cpp therefore reports against the video driver
rather than trusting SDL. SDL_SetWindowPosition fails outright on Wayland —
a desktop pet cannot place itself — so X11/XWayland is the supported Linux path
for now.
Linux tray on GNOME. SDL's tray backend talks to libayatana-appindicator
over D-Bus. Vanilla GNOME Shell has no StatusNotifierItem host, so
SDL_CreateTray() succeeds and no icon appears. That silent failure is not
detectable from SDL; GNOME users need the AppIndicator extension.
CI
Two runners, deliberately not one.
.forgejo/workflows/build.yml is the self-hosted podman path: Linux natively
on debian:12, Windows cross-compiled with mingw-w64 on debian:trixie — the
newer base because the Windows job runs its own output under wine, and that
needs wine 10.
.github/workflows/build.yml builds all three natively on hosted runners, and
is the only way macOS gets built at all (see docs/macos.md for why it cannot
be cross-compiled). Each job runs the tree it packaged, not the one it built:
every failure this project has actually hit — a missing stdlib zip, a missing
.pyd directory, an absolute macOS install name — links cleanly and fails at
startup.
License
MIT, © 2026 John McCardle. See LICENSE.
A packaged build redistributes seven other works in binary form, under their own
terms: SDL3 (zlib), stb (MIT / public domain), CPython (PSF-2.0), pip (MIT),
JetBrains Mono (Apache-2.0), libwebp (BSD-3-Clause, with its patent grant) and
Dear ImGui (MIT). Their notices ship in licenses/ inside the package, with an
index:
gobboclippy-0.6.0-Linux/
licenses/
README.txt what each file covers
gobboclippy-MIT.txt
SDL3-zlib.txt
stb-MIT-or-public-domain.txt
CPython-PSF.txt
pip-MIT.txt
JetBrainsMono-Apache-2.0.txt
libwebp-BSD-3-Clause.txt
libwebp-PATENTS.txt
dear-imgui-MIT.txt
libwebp is two files and one work: the BSD grant is accompanied by a separate patent grant, and shipping the licence without it would be shipping half the terms.
Each is copied from the tree it belongs to — the pinned SDL checkout, the pinned stb checkout, the interpreter being bundled — so a notice cannot describe a different version than the one shipped. A missing notice is a configure error.
The font is the exception: a .ttf has no source tree to take a notice from,
so its terms are vendored beside it at assets/JetBrainsMono-LICENSE.txt and
copied into licenses/ as well.
On Windows that CPython notice matters more than the others: it carries
"Additional Conditions for this Windows binary build", Microsoft's terms for
the Distributable Code linked into python3XX.dll, the .pyd modules and the
vcruntime140*.dll beside them. The Windows kit is required to supply it.