Imported from Croissander/omp-tray-ext (
AGENTS.md). Install upstream withnpx skills add Croissander/omp-tray-ext. Copyright stays with the author.
AGENTS.md — omp-tray-ext
A native Linux status-bar tray for Oh My Pi (omp) that reflects
agent state — idle, working, errored. Implements the freedesktop
StatusNotifierItem (SNI) spec over the DBus session bus. No Electron/GTK/Qt;
the icon bytes are drawn pixel-by-pixel and pushed as IconPixmap ARGB.
This file is the ground-truth brief for any agent (or human) working in this repo. Read it before touching code.
Architecture
omp process (transient) tray daemon (tied to omp lifetime)
┌─────────────────┐ ┌──────────────────────┐
│ index.ts │ SetState(s) │ daemon.ts │
│ spawn daemon │──── DBus ───▶│ owns SNI on bus │
│ forward events │ │ spinner timer │
│ /tray command │ │ IconPixmap (ARGB) │
└─────────────────┘ └──────────┬───────────┘
▼
KDE / GNOME / waybar panel
index.ts— extension entry; spawns the daemon detached, forwards omp lifecycle events to it, registers the/trayslash command.daemon.ts— owns the SNI item +org.omptray.Daemoncontrol interface; renders the spinner and reacts toSetState/Stop. Lifetime mirrors the omp process.ipc.ts— shared DBus client:daemonAlive,sendState,stopDaemon. Each call opens its own session-bus connection, so there is no cross- connection FIFO — the controller serializes state changes itself (see below).controller.ts— maps omp lifecycle events toidle/working/errorstates and forwards them. Exposes.statefor the/tray debugcommand.icons.ts— monochrome glyphs:>chevron +_,!!, 8 spinner frames.
State mapping
| Agent state | Tray icon | SNI Status |
Fires on |
|---|---|---|---|
| idle | >_ prompt glyph |
Passive→Active |
session_start / agent_end |
| working | spinning ring (~8 fps) | Active |
agent_start / before_provider_request / tool_execution_start |
| error | !! double exclamation |
NeedsAttention |
tool_result with isError (auto-clears after 5 s) |
Key invariants (do not break)
- State transitions are serialized through
TrayController.chain. EachsendStateopens its own DBus connection with no cross-connection ordering, so two concurrent transitions can reorder — a stale "working" landing after a later "idle" leaves the tray spinning forever. The promise-chain forces call B to wait for call A'ssendStateto resolve before starting. The chain also.catch()es so a failing send can't wedge every later state. - The daemon is tied to the omp process lifetime. Spawned at load,
stopped on
session_shutdown. A last-resortprocess.on("exit")SIGTERM covers signal-based exit paths (SIGHUP/SIGTERM) that skipsession.dispose(). The daemon's own SIGTERM handler removes the SNI item cleanly.process.killon an already-dead PID throws ESRCH — swallowed. - Error is transient.
flashError()shows!!for 5 s, then reverts. It is routed through the same chain so an un-awaited error send can't overtake a later idle/working. - Tray IPC never blocks the agent loop.
sendStateis a no-op if the daemon isn't running. Everyawaitof it is fire-and-forget at the omp level.
omp extension authoring — what applies here
Authoritative docs: https://omp.sh/docs/extension-authoring and https://github.com/can1357/oh-my-pi/blob/main/docs/extensions.md. Summary of the rules that govern this extension specifically:
- Factory signature.
export default function ompTray(pi: ExtensionAPI). Register handlers/tools/commands during load; runtime actions (sendMessage,setActiveTools, …) throwExtensionRuntimeNotInitializedErrorif called synchronously during module evaluation. We only register + spawn the daemon on load — fine. - Command names must not clash with built-ins. omp reserves
BUILTIN_SLASH_COMMAND_RESERVED_NAMES(built from the builtin registry). The extension runner silently skips a registered command whose name is in that set, logging a diagnostic. That is whydebuglives as/tray debug, not as a separate/debug(omp ships a builtin/debug). - Command argument autocompletion is provided via
getArgumentCompletions(argumentPrefix). Convention (mirroring the builtins): returnAutocompleteItem[] | null. Filter by prefix;nullwhen no match or after a space (past the subcommand). Each item is{ value: "<sub> ", label, description? }— note the trailing space invalue. Must be side-effect-free (it runs synchronously on each keystroke). - No DBus/GUI deps beyond
dbus-next. Pure JS, no native build. The daemon is spawned withbun run(TS imported directly — omp loads extensions via Bun). - Logs. omp writes structured logs to
~/.omp/logs/omp.$(date +%F).log. The daemon logs to its own stderr (currentlystdio: "ignore"on the spawn — flip to a file if debugging spawn failures). Extension logs viapi.logger. - Disable temporarily without removing the file:
The derived name is the filename stem / directory name — here# ~/.omp/agent/config.yml disabledExtensions: - omp-tray-extomp-tray-extperpackage.json#name.
Slash command surface (/tray)
/tray status — show daemon running state (default)
/tray stop | off stop — stop the daemon
/tray restart restart — stop + re-spawn
/tray working working — force the tray to working
/tray error error — force the tray to error
/tray debug debug — show plugin/daemon state for troubleshooting
All subcommands are prefix-filtered by getArgumentCompletions (typed in the
editor → dropdown). /tray debug reports: daemon running/ready/PID, plugin-side
TrayController.state, daemon script path, active model, agent idle, cwd.
Development practices
Environment
- Runtime:
bun(the extension and daemon import TS directly; omp loads via Bun). Required by users at runtime too. - Target: a Linux desktop with a DBus session bus and an SNA host (KDE Plasma, GNOME + Appindicator, waybar tray module, swaync/swaybar, …).
- TypeScript is a
peerDependency;@types/bunis the only dev type source.@oh-my-pi/pi-coding-agentis not installed locally — it's an ambient host-provided type. To resolve types during editing, it must be available on the host (it ships with omp at~/.omp/plugins/node_modules/...and in the bun cache). Do not add it todependencies; do not import it at runtime.
Local dev loop
bun install # one-time; dbus-next only
bunx tsc --noEmit # typecheck
bun test controller.test.ts # self-checks for the state chain
To test live against omp:
# Option A — installed as a user extension
ln -s "$PWD" ~/.omp/agent/extensions/omp-tray-ext # or git clone there
# Restart omp; the daemon spawns at load and the tray appears in the panel.
# Option B — load once via CLI flag
omp --extension ./.
Iterating on daemon.ts / index.ts requires reloading omp — the extension is
imported at startup, not hot-reloaded. daemonReady is re-checked on
session_start, so /compact or a session switch will re-ensure the daemon.
Code conventions
- Ponytail by default. Lazy = efficient, not careless. The ladder:
does it need to exist? → stdlib → native platform feature → already-installed
dep → one line → minimum code that works. Mark deliberate simplifications with
// ponytail: <shortcut>; upgrade path <X>. - No unrequested abstractions. No interface with one implementation, no factory for one product, no config for a value that never changes. Deletion over addition; boring over clever.
- Never simplify away input validation at trust boundaries, error handling that prevents data loss, security.
- Each non-trivial logic unit leaves one runnable check behind — an
assert-baseddemo()/__main__self-check or one smalltest_*.py(here:controller.test.ts). Trivial one-liners need no test. - DBus IPC patterns: every connection is opened with a connect timeout and
disconnected in a
finally.process.kill/bus.disconnecton an already-dead target throws — always wrap intry {} catch {}. - Use
Promise.withResolvers()instead ofnew Promise((resolve) => ...). - Don't extract one-expression functions. Inline unless the name creates a durable contract (test seam, DI boundary, public API, type guard).
Editing discipline
- Prefer the
edittool for surgical changes;writeonly for new files or full overwrites. - Re-read a file before editing if a tool failed or the file changed since.
- Grep/glob to locate targets; read sections, not whole files. Don't open files hoping.
- Run
lsp referencesbefore modifying exported symbols — missed callsites are bugs. - Run
bunx tsc --noEmitand the affectedbun testafter every non-trivial change. Tests assert behavior, not current state.
Release / version policy ⚠️
After every bigger change, bump the version in package.json, tag, and push
to GitHub. "Bigger" = any user-visible behavior change, new command, new state,
DBus contract change, or anything touching daemon.ts/ipc.ts/controller.ts
invariants. Pure refactor of identical behavior with no observable change does
not require a bump.
Conventions:
package.json#versionand the matching git tagvX.Y.Zare the source of truth. Both must move together: bump the field, then tag the same commit.- Semver-leaning:
1.0.1 → 1.0.2for fixes/patches, →1.1.0for new commands/states, →2.0.0for a DBus contract break. - Tags are required for
omp installto detect updates: a spec without a ref pins the resolved HEAD SHA intobun.lockon first install and is then treated as satisfied —ompwon't re-resolve against upstream. A moving ref (#master) or a tag (#v1.1.0) gives omp/bun a reason to compare. - bun 1.3.x does NOT parse
#refin scp-style git URLs (git@github.com:...git#tagfails with "no commit matching"). Use thegithub:owner/repo#refshorthand, which omp translates togit+ssh://and resolves the ref correctly.
Workflow:
# 1. Make the change; verify.
bunx tsc --noEmit && bun test controller.test.ts
# 2. Bump version in package.json (edit version: "x.y.z").
# 3. Commit, tag, push.
git add -A
git commit -m "<scope>: <what changed> (vX.Y.Z)"
git tag vX.Y.Z
git push origin master --tags
Install/update from a tag:
# Users install a pinned version (use the github: shorthand — git@...#tag is not
# parsed by bun):
omp install github:Croissander/omp-tray-ext#v1.1.0
# Or the moving master ref (re-resolves more eagerly than a bare spec):
omp install github:Croissander/omp-tray-ext#master
# Force-reinstall to pick up a new tag/master HEAD after a stale lockfile:
omp install --force github:Croissander/omp-tray-ext#v1.1.0
If omp install --force still resolves to the old SHA (bun sometimes treats an
existing satisfied lockfile entry as enough), remove the stale resolution
manually and reinstall:
rm -rf ~/.omp/plugins/node_modules/omp-tray-ext
# drop the "omp-tray-ext" line from ~/.omp/plugins/package.json and bun.lock,
# then reinstall with the new spec:
omp install github:Croissander/omp-tray-ext#v1.1.0
Further reading
- omp extension authoring — https://omp.sh/docs/extension-authoring
- omp extension runtime internals — https://github.com/can1357/oh-my-pi/blob/main/docs/extensions.md
- SNI spec — https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/
- dbus-next — https://github.com/dbusjs/node-dbus-next