Menu Bar App
GlomerisMenuBar.app is a native SwiftUI menu-bar app (Epic HORO-1043,
“Glomeris MVP 2.0”) that gives the glomeris CLI a graphical front end. See
Installation for how to
download and open it.
Thin client, not a second decision-maker
This is the project’s core invariant for the app, stated verbatim in the
Swift source (macos/GlomerisMenuBar/Sources/GlomerisMenuBarApp.swift):
GlomerisMenuBar is a THIN CLIENT ONLY, over the existing Rust
glomerisCLI’s--jsonoutput. This Swift codebase renders and lets a human approve/decline what the Rust CLI already decided.NO policy classification (
AUTO_SAFE/ASK/PROTECTED), NO evidence correlation, NO action planning, and NO filesystem execution logic may EVER live here.
In practice this means every screen below is a formatting step over one of
glomeris’s own --json reports (status, daemon status, detect,
explain, history, actions history, execute, autopilot show|enable|revoke), spawned as a subprocess. The app never re-derives “is
this safe to clean” from
policy_label or reasons — it reads the already-computed executable,
offered_actions, and refusal_reason fields the CLI provides for exactly
this purpose (see CLI Reference). If you trust the CLI’s
policy decisions on the command line, you’re trusting the exact same
decisions in the menu bar — the app has no separate opinion.
How the popover reads
Every section is a titled card in a scrolling column, ordered by the questions you open the panel with: what the disk is doing now, what could be reclaimed, what has already happened. Three presentation rules hold across all of them:
- Plain language first, the CLI’s own token second.
PRESSUREDreads as “Running low”,aborted_by_revalidationas “Stopped safely” — and the raw token stays visible beside or beneath it, because this is an evidence-first product and you have to be able to match what the panel says against--jsonand against the docs. - Three separate axes, never merged. Storage impact (how much space),
safety (what the policy allows) and evidence quality (how sure the CLI
is) are shown as distinct badges. A large
AUTO_SAFEcandidate is an opportunity, not a hazard; a smallPROTECTEDone is still protected. Size is therefore drawn in a neutral tone at every magnitude. - Colour is never the only signal. Every badge carries a symbol and a
word as well as a tint, so the state survives greyscale, a
colour-vision deficiency and a tinted wallpaper. A refusal
(
PROTECTED) is deliberately not painted like a failure: it is the policy working, and there is nothing for you to fix.
Disk space, and Background monitor
Two cards, polled on appear and every 10 seconds while the popover is open. The two facts are deliberately never collapsed into a single “healthy” indicator:
- Current disk pressure (
glomeris status --json) — used percent, free space, and pressure state (one ofHEALTHY,WARN,PRESSURED,CRITICAL,EMERGENCY). - Daemon health (
glomeris daemon status --json) — whether thelaunchdagent is loaded, and how long ago the poll loop last recorded a heartbeat ("Last heartbeat: 42s ago", or"no heartbeat recorded").
A launchd-loaded-but-wedged daemon and an actually-polling one stay
visibly distinguishable — the app never merges loaded and
heartbeat_age_secs into one boolean.
Reclaimable space
Shows the most recent glomeris detect --json scan (“Last scanned:
<time>”) plus a Refresh button. Nothing is scanned automatically:
there is no appear-triggered scan, no timer, and no background polling
loop for candidates — detect runs only when you explicitly tap Refresh,
streaming live per-detector progress via --progress-json while it works
(button label switches to “Scanning…”).
Five states that are easy to conflate are kept distinct, because each is a different claim about your disk:
| State | What it says |
|---|---|
| “No scan yet” | Nothing has been looked at. Not a clean bill of health. |
| “Nothing worth reclaiming” | Scanned, and there is genuinely nothing — good news. |
| “Nothing found where Glomeris could look” | Scanned, found nothing, but part of the search never answered — so whatever is there is unknown rather than absent. Names which checks did not finish. |
| “No candidates match this filter” | Things were found; you are just not looking at them. |
| A scan failure | Says what failed. An empty list is never shown in its place. |
The third state also has a non-empty counterpart (HORO-1484): when a detector
fails but others still found candidates, the rows are shown as normal with
“This list may be incomplete” added below them. The rows are real and stay
on screen; what they may not do is look like the whole account. A detector
whose tool is simply not installed is not a failure and produces neither
message — docker being absent is normal, brew being asked and failing is
not.
Each row shows the candidate’s kind, what cleaning it would free, and its safety verdict in words. Tapping a row opens its detail view — there is no inline “Clean” button in this list.
Rows arrive in the CLI’s own order — biggest reclaimable size first — and
are shown exactly as detect returned them. The app does no ranking of its
own: deciding what matters most is a judgment, and it belongs next to the
evidence in Rust rather than in a thin client that would then drift from it.
The View options menu (the funnel next to Refresh) offers two things:
- Order — “Biggest first”, which is the CLI’s order untouched, or “Path (A–Z)”, an alphabetical index for finding a resource whose path you already know. There is deliberately no third “largest first” option: that is “Biggest first”.
- Show — All, Safe to reclaim, Asks first, Protected, or Not enough evidence. This hides rows and does nothing else; it cannot enable, authorise or perform anything, and the list has no action affordance for it to unlock. Protected items are a first-class filter value rather than something hidden by default: “what on this machine is off-limits, and why” is a reasonable question, and quietly omitting them would teach you that protection means invisibility.
When a filter is active the header reads “N of M items” so a shortened list can never be mistaken for a smaller problem.
Size is not safety
A third badge appears on the rows worth pausing on — “Biggest wins” or
“Worth a look” — using the impact_tier the CLI already computed. It is an
emphasis hint about magnitude only, carries no safety colour, and appears on
no more rows than deserve it (ordinary and unmeasured candidates get no badge
rather than a badge saying “normal”, which would be noise).
Storage impact, safety, and evidence confidence are three separate axes and the popover never collapses them. A large candidate may be protected; a small one may be perfectly safe to reclaim. Nothing is distinguished by colour alone — every badge pairs its colour with a distinct symbol and words — and VoiceOver reads each row as size, then safety, then emphasis, so the badge is never the only route to the fact.
Candidate detail
Opened by tapping a candidate row; this is the sole place a “Clean” button
exists in the whole app. It calls glomeris explain --json for the exact
resource, which supplies fingerprint_token — the same token
execute --observed-fingerprint later pins consent to. This is also why
there’s no per-row Clean button in the candidates list above: cleaning a
resource always goes through one explain call that captures the
fingerprint the confirmation flow needs.
The sheet is ordered by the questions you have when you open it: may I
clean this, is it worth cleaning, on what evidence — and last, collapsed
behind a disclosure, the literal values explain --json returned, which
stay selectable so they can be quoted in a bug report.
- The Clean button’s enabled state reads
executablefrom theexplainreport directly — never a re-derived guess frompolicy_label. - If the resolved action
requires_confirmation(anASKorUNKNOWN_INCOMPLETEcandidate), tapping Clean shows a confirmation alert before doing anything. - Confirming runs
glomeris execute --action-id <id> --resource-id <id> --confirm-ask --observed-fingerprint <token> --progress-json, using the exact fingerprint token captured from theexplaincall above — never a fingerprint freshly re-observed at click time, which would defeat the whole point of fingerprint-pinning. - The outcome (
succeeded,failed,aborted_by_revalidation, or a specific refusal reason likeprotected/ask_consent_mismatch) is rendered from the realExecuteReport/ExecuteRefusalReportJSON the CLI printed — one specific message per outcome, not a generic success/failure toast.
AI Plan
The one card that talks to the internet, and only ever when you press its button. There is no timer behind it, nothing runs when the popover opens, and nothing retries — asking a provider costs money, so asking has to be something you did. Until you press it the card says exactly that: “Nothing has been sent anywhere.”
Pressing Ask AI for a plan runs glomeris llm-plan --json --progress-json with your project roots, streams the same per-detector
progress the Reclaimable space card shows, and offers a Stop button that
terminates the CLI child rather than just abandoning the result. Above the
button, permanently: “The model recommends. Glomeris decides what may run.”
and a note that asking costs money and that “Settings shows exactly what would
be sent, without sending it” — the privacy preview described under
Preferences — AI Provider below.
Each suggestion is one row, and every row is split down the middle by who said what:
- The machine’s half — safety class, storage impact, the size tier, and the evidence/confidence pair — is rendered as badges from the same vocabulary the candidates list uses, and comes from the real policy engine. It reads identically whether or not a provider was ever contacted. Below them, in plain words, is what Glomeris is willing to do: “Glomeris is willing to run this”, “Glomeris will ask you to confirm this before anything runs”, or the refusal, verbatim.
- The model’s half — its rationale — is an attributed, italic quotation under a “The model says” label. It is deliberately not a badge. A badge in this app means a verdict was reached; putting a model’s sentence in one would dress an opinion as a finding. A screen reader hears the machine’s verdict first, then “The model’s reason, which is advice and not a verdict: …”.
The rows appear in the order the provider returned them, and the card says so
(“The model’s order. Glomeris’s own ranking is the list above.”). No rank
number is drawn on a row, and the model’s own priority field is carried in
--json but never shown: the list position already makes that claim once, and
two competing numberings would read as though one of them were authoritative. Nothing in the app re-sorts the list either — Glomeris’s
ranking is the Reclaimable space card, which is why the AI Plan card sits
below it.
A recommendation cannot make anything runnable. A model that confidently
describes your SSH private key as a stale build directory gets its sentence
quoted, next to a PROTECTED badge and a refusal, with no action offered — the
row is built from the same executable/offered_actions/refusal_reason
fields the candidates list reads, which the CLI computed before the provider
was contacted. Suggestions naming a resource Glomeris never found, or an action
it does not have, are dropped by the CLI and the count of them is printed under
the list rather than quietly swallowed.
Tapping a row opens the same candidate detail sheet as the candidates list,
which issues its own explain call and owns the only Clean button in the app.
A plan item carries no fingerprint token, so there is no shortcut past that —
cleaning something a model suggested goes through exactly the path, and the
same confirmation, as cleaning something you found yourself.
Six situations, six distinct messages: never asked; asking; no provider
configured; the provider answered with nothing; the provider call failed
(quoted); and output this app could not read, which means the app and the
glomeris on PATH are different versions. The first three are not failures
and are not coloured like failures. The no provider configured message says
why a shell user can still land there — a Finder-launched menu-bar app inherits
no shell environment, so GLOMERIS_LLM_* variables exported in a terminal are
invisible to it — and is the one state with a Set up an AI provider… button
beside it, which opens Settings. The button is beside the message rather than
inside it on purpose: the copy layer is a pure token-to-words mapping, and a
message that could carry a control would be a message that could act.
Disk space history, and What Glomeris has done
Two independent, already-computed lists in two cards, each polled the same way as the status section:
- Disk space history — the last N pressure transitions from
glomeris history --json(e.g.WARN -> CRITICAL, with the used-percent/free-space reading at the time). The two ends of each transition are named exactly as the status card names them, because they are the same enum. - What Glomeris has done — the last N real-execution records from
glomeris actions history --json: which action ran against which resource, its policy label, outcome, and which ofexecute/free/emergencyproduced it. “Triggered by” is spelled out in words, because whether something was cleaned that you did not ask for is the question this card exists to answer. TheAUTO_SAFE-vs-refused/aborted visual distinction readsoutcome/abort_reasondirectly, never thepolicy_labeltext — andaborted_by_revalidationreads as “Stopped safely” rather than as a failure, because the resource changed between checking and acting and so nothing was touched.
An empty list in either card says only that it is empty. Neither claims an all-clear it cannot support: an empty pressure history could mean the disk has been steady, or it could mean nothing has been watching it, and the report does not distinguish those.
Neither list accepts a --project-root flag — both read global
daemon-state files (history.tsv/actions.jsonl), not project-scoped
detection state.
Preferences — Project Roots
The popover’s Project roots… footer button — or Cmd+, — opens Settings,
whose Projects tab is a simple list with add/remove controls for the
project-roots preference, backed by a small local store. (The footer exists
because the menu-bar item opens a window rather than a menu, so the popover is
the app’s only surface; Quit is there for the same reason.)
These are the same paths you’d otherwise pass repeatedly as --project-root <path> on the command line — the CLI’s cargo/node detectors only look
under directories they’re told about. This view is pure presentation: it
edits the stored list and does not itself call detect/explain/
execute; the roots are appended as --project-root arguments the next
time another card (Disk space, Reclaimable space, …) spawns the CLI.
Preferences — AI Provider
Settings’ second tab (HORO-1309) is where the BYOK provider is configured, for the common case of someone who runs the app from Finder and has never exported anything in a shell. Four cards, in the order the questions arise:
AI Provider — the API root address and the model name. Under each field, a
row saying where that field’s effective value came from: Configured here,
From the environment, or Not set. Per field, what you type here wins over
what the app inherited; a field left empty falls back to the inherited
GLOMERIS_LLM_* variable, including falling back to it being absent. Clearing
a field therefore returns you to the environment rather than switching a working
setup off. The rule is that the settings screen never lies: if the field shows
an address, that is the address used. Neither field has a Save button, because
neither has unsaved state — they write through as you type. Nothing here
validates the URL or knows which models exist; the CLI does both, and
Test connection is what reports it.
API Key — a SecureField, a Save button, and a Remove key button
marked as the destructive action it is. The key goes into the login keychain,
and from there into the environment of the glomeris child process at the
moment one is spawned. It is never a command-line argument (visible to ps, and
refused by the CLI by flag name), never this app’s preferences, never a file the
app writes, never a log line, and never displayed: there is no reveal control,
nothing reads it back, and the typed text is cleared as soon as it is handed to
the keychain — whether the write succeeded or not, so a failure cannot leave it
sitting in a field behind an error message.
Test Connection — one glomeris llm-check --json, described honestly as
“one tiny request — two words”, disabled until all three of the address, the
model and the key are available. The result is one of five outcomes in plain
words, each reading differently because each has its fix in a different place:
a rejected credential, an unreachable host, a reply the app could not use, a
base URL that cannot work, and success — which reports the path it posted to,
the model, and what came back. See
BYOK LLM Planner
for the outcome table. A failure is carried verbatim; nothing is paraphrased
into something more reassuring than what happened.
What Gets Sent — one glomeris llm-plan --print-payload --json, with the
same project roots a real plan would use, so it previews your payload rather
than a generic example. It splits the result into Leaves this Mac (the two
prompts, with a character count) and Stays on this Mac (the wire-alias
table, which is where the absolute paths are). The second group is blue, not
green: green in this app means AUTO_SAFE or succeeded, and data being
withheld is a deliberate hold, not a success.
Opening that preview sends nothing and cannot — --print-payload returns before
a provider is constructed — and the preview is the one command on this screen
that runs without the credential in its environment. Neither button does
anything until pressed: there is no timer, nothing runs when the window opens,
and nothing retries.
Preferences — Autopilot
Settings’ third tab (HORO-1310). Autopilot is the one feature
that grants standing permission to delete without being asked again, and the
person granting it is the least likely to be reading --help — so a grant that
could only be made and read on the command line would be a grant most users
would never read. This tab is where AC 6 of that ticket (“see exactly what
Autopilot is authorized to do before enabling it”) and AC 7 (“revoke
immediately”) are met for someone who never opens a terminal.
It is still a thin client. Every choice it offers arrives as data from
glomeris autopilot show --json: the checkboxes are allowlistable_kinds, the
steppers’ upper bounds are ceilings, the picker’s options are
pressure_states, the one question that may be answered in advance is
preauthorizable_reasons, and the “never available” lists are
never_allowlistable_kinds, never_preauthorizable_reasons and
never_executable_labels verbatim. If the CLI cannot be reached the tab draws
no form at all and says so, rather than falling back on its own idea of what
Autopilot allows — a settings screen offering a limit it invented would be a
policy decision made in Swift.
Eight cards, in the order the questions arrive:
- Autopilot — on or off, then what is in force right now in the CLI’s own
figures (including
max_bytes_human, so the number you read is formatted by the thing that enforces it), the envelope’s path, a Revoke now button and a Refresh. An enabled grant is drawn in the caution tone, never the critical one: a standing authorization you chose is not a fault. - What It May Reclaim — one checkbox per allowlistable kind, in the CLI’s own order, each with its plain-language name and what it is. Below them, the kinds that are never available whatever is granted here.
- How Much, Per Run — three steppers (actions, whole gigabytes, seconds), described as three independent budgets a run stops at whichever it reaches first, with the hard ceilings quoted underneath.
- When It May Act — the disk-pressure floor, including “Whenever there is something to reclaim” for no floor at all.
- Answering In Advance — the narrow
ASKpre-authorization, onekind:reasonpair at a time. Until a kind is ticked there is nothing to answer, and the card says that instead of offering a consent that would apply to nothing. - Grant This / Change This Authorization — the write. Disabled until at least one kind is ticked, and it says so.
- What the AI Decides — the
ai_authoritysentences, quoted rather than paraphrased, and the labels Glomeris will never delete whatever is authorized here and whatever a model recommends. - See What It Would Do — a copyable
glomeris autopilot run --dry-run.
Five properties of this screen are worth stating outright, because each is a way it could have been quietly wrong:
The form is pre-filled from what is in force, because enable replaces the
whole envelope. glomeris autopilot enable does not merge into the previous
grant (see Autopilot for why), so a form that
started from defaults would let you narrow one field and silently reset the
other five. Pressing Enable without touching anything re-grants exactly what
was already there. For the same reason the byte budget rounds up to the
next whole gigabyte: a 1.5 GiB grant shown as “1 GB” would mean that merely
opening this window and saving shrank a budget nobody touched.
The floors here are this screen’s, and they only narrow. The steppers stop
at 1 action, 1 GB and 5 seconds. The CLI accepts --max-actions 0 — an enabled
grant that can do nothing — which is coherent as an API and pointless as a
setting, so this window does not offer it. Nothing here can ask for more than
the CLI’s ceilings, which is a property of the values the report carries rather
than of a number typed in Swift.
Withdrawing a kind withdraws its advance consent with it. Unticking a kind
prunes any kind:reason pre-authorization that named it, so consent cannot
outlive the kind it applied to, or quietly come back when the kind is re-ticked.
There is no way to start a run from this window. Not a Run button, not a
dry-run button, no timer, nothing on appear: the only thing that runs
automatically is the read. autopilot run is also the one autopilot verb with
no --json, so the thin client has nothing to parse if someone adds a button
later without thinking about it. A run deletes, and the argument for keeping
deletion on a command you typed is the same one that keeps glomeris emergency
out of the app.
A revocation that failed is never reported quietly. The two write
directions fail in opposite ways — a failed enable leaves you with less
authority than you asked for, a failed revoke leaves standing deletion
authority in force — so they have separate wording, and every failed revoke
says that the authorization still is in force and how to withdraw it with
glomeris autopilot revoke. Exit 0 with output this app cannot read is treated
as a successful write in both directions, because the CLI prints its report
after the envelope has been saved.
The grant does not expire on its own. It is a file, it survives quitting and restarting, and it stays in force until it is revoked here or on the command line — which the card that grants it says in those words, because an authorization the user believes is temporary would be the worst kind of quiet.
How the app finds the glomeris CLI
Every screen spawns the CLI, so the app has to decide which binary that is. It checks these locations in order, and uses the first one that exists and is executable:
- Inside the app bundle, at
Contents/MacOS/glomeris. Release builds ship the CLI here, and it wins because it is version-matched to the app and covered by the bundle’s signature. Debug builds contain no embedded CLI, so development is unaffected. - Each absolute directory in
PATH, in order. Relative entries — including the empty entry that shells read as “the current directory” — are ignored, so nothing can substitute a binary by writing a file namedglomerisnext to the running process. /opt/homebrew/bin, then/usr/local/bin— the Homebrew prefix on Apple Silicon and on Intel respectively, and where Installation tells you to put a binary from a release tarball or a source build.
Step 3 is not redundant with step 2: a GUI app does not inherit your
shell’s PATH. An app launched from Finder gets
PATH=/usr/bin:/bin:/usr/sbin:/sbin, which contains neither Homebrew
prefix — so PATH alone would not find a brew installed CLI, even though
running glomeris in a terminal works fine.
Resolution happens on every invocation, not once at launch, so installing the CLI while the app is already running takes effect at the next poll without a restart.
If no binary is found, each section says so and lists the locations it searched, rather than failing silently or naming a path it only assumed.
Seeing which binary is in use
The Command-line tool card names the binary the app resolved: its full path, which of the three rules above chose it, and a fingerprint — the first twelve characters of the SHA-256 of its contents, with the whole hash in the tooltip.
The fingerprint is there because the version number cannot do this job. Two
glomeris binaries on one machine both reported version 0.2.0 while
disagreeing about whether an action with no scoped path may be offered at
all: the version is stamped from the crate version, so it does not change
between merges and is not a build identity. The hash is.
A release build records the hash of the CLI it ships, so the card can say whether the binary in use is that one. The four things it can say:
| The card says | What it means |
|---|---|
| Matches this app | The resolved binary is the one this app ships. |
| Not the build this app ships | Both hashes are known and differ. The app still works, and still drives that binary — what it does may differ from what the app describes. |
| Nothing to compare against | This app records no expected hash. Normal for a locally built app; see below. |
| Cannot be checked | The binary can be run but its contents could not be read, so no comparison was possible. |
A locally built app embeds no CLI. The embed step lives in the release
workflow, not in the Xcode project, so an app built with xcodebuild — or
from Xcode — contains no Contents/MacOS/glomeris and always falls through
to PATH or the Homebrew prefixes. Whatever is installed on the machine is
what your build drives, and that may be older or newer than the tree you
built from. A local build also records no expected hash, which is why the
card reads “Nothing to compare against” rather than reporting a mismatch.
If you are testing a change to the CLI from a local app build, the path on
the card is the one to check: cargo build alone does not put a binary
anywhere the app looks.
Every screen maps back to a CLI command
| Card | CLI command(s) |
|---|---|
| Disk space | glomeris status --json |
| Background monitor | glomeris daemon status --json |
| Reclaimable space + Refresh | glomeris detect --json --progress-json |
| AI Plan — Ask AI for a plan | glomeris llm-plan --json --progress-json |
| Settings → AI Provider — Test connection | glomeris llm-check --json |
| Settings → AI Provider — Show what would be sent | glomeris llm-plan --print-payload --json (no provider contacted) |
| Settings → Autopilot — on appear, Refresh | glomeris autopilot show --json |
| Settings → Autopilot — Enable / Save changes | glomeris autopilot enable --kinds <tag,...> --max-actions <N> --max-bytes <N> --max-duration <secs> --min-pressure <state|none> [--preauthorize-ask <kind>:<reason>]... --json |
| Settings → Autopilot — Revoke now | glomeris autopilot revoke --json |
| Candidate detail | glomeris explain <resource_id> --json --progress-json |
| Clean (with confirmation) | glomeris execute --action-id <id> --resource-id <id> [--confirm-ask --observed-fingerprint <token>] --json --progress-json |
| Disk space history | glomeris history --json |
| What Glomeris has done | glomeris actions history --json |
See CLI Reference for the full flag/exit-code/JSON-shape reference behind every one of these.