agent-watchdog-menubar
A native macOS menu bar app that shows what the agent-watchdog daemon
(apps/watchdog) is seeing. It reads the daemon’s status file and renders it; it
never inspects transcripts or computes thresholds itself, so it always agrees
with the daemon.
The status item shows an eye icon plus the worst session’s context percentage, coloured by the level the daemon derived for that session:
| Icon | Title | Colour | Meaning |
|---|---|---|---|
eye |
23% |
green | daemon running, worst session below the warn line |
eye |
-- |
green | daemon running, no sessions |
eye.trianglebadge.exclamationmark |
41% |
orange | worst session at or above the warn threshold |
eye.trianglebadge.exclamationmark |
81% |
red | worst session at or above the critical threshold |
eye.slash |
81% |
grey | status file is stale; the number is the last known |
eye.slash |
-- |
grey | daemon stopped, not running, or file unreadable |
In icon-only mode (see below) the title is dropped and the percentage moves into the tooltip; the icon and colour are unchanged.
Build and run
Requires Xcode command line tools with Swift 5.9 or later. The package declares
swift-tools-version:5.9 and targets macOS 13.
yarn workspace @agent-watchdog/menubar build # swift build -c release
yarn workspace @agent-watchdog/menubar test # swift test (MenubarCore only)
.build/release/agent-watchdog-menubar # run it (from apps/menubar)
To have the daemon’s launch agent start the menu bar app alongside the daemon, pass the binary to the watchdog CLI when linking or installing:
yarn workspace @agent-watchdog/watchdog start link --menubar-bin "$PWD/apps/menubar/.build/release/agent-watchdog-menubar"
yarn workspace @agent-watchdog/watchdog start install --menubar-bin "$PWD/apps/menubar/.build/release/agent-watchdog-menubar"
The app has no dock icon and no bundle; quit it from its menu. Diagnostics go to
stderr, prefixed agent-watchdog-menubar:.
Status file location
By default the app reads ~/.local/state/agent-watchdog/status.json, the path
the daemon writes. Set AGENT_WATCHDOG_STATUS_FILE=/some/path.json to point it
elsewhere (useful for testing with a hand-written file).
The file is polled every 3 seconds with a timer, and again whenever the menu opens. A file watch is deliberately not used: the daemon replaces the file atomically by rename, which would leave an inode-based watch pointing at a dead file.
Icon-only mode
The full status item (icon plus 81%) is about 76 pt wide; icon only it is
about 37 pt, which fits menu bars where the full item does not. Two sources
decide it, in this order:
-
The environment:
AGENT_WATCHDOG_MENUBAR_COMPACT=1forces icon only,AGENT_WATCHDOG_MENUBAR_COMPACT=0forces the full item, and either one disables the menu toggle (its title then says it is set by the environment). Use this in the launchd plist when the app is started byagent-watchdog install --menubar-bin. -
A stored preference, toggled from the Icon only menu item (checkmark when on). The app has no bundle, so the preference lives in the domain named after the executable and can be set without seeing the menu:
defaults write agent-watchdog-menubar compact -bool true defaults delete agent-watchdog-menubar compact # back to the full item
The change applies on the next refresh (at most 3 seconds). The percentage is
still in the tooltip (Daemon running (last poll 12s ago) · worst session 81%)
and in every session row of the menu.
Hidden-item warning
After every refresh the app asks the window server whether the status item’s
window is on screen (CGWindowListCopyWindowInfo, kCGWindowIsOnscreen). When
the menu bar is full, macOS parks the newest item under the notch or off the
end of the bar: the app keeps running and logging, but nobody can see it. On
the transition from visible (or never seen) to hidden the app:
- logs one line to stderr,
agent-watchdog-menubar: status item is hidden (menu bar full or behind the notch); ..., with the window position and width it measured; - posts one notification,
agent-watchdog menu bar icon is hidden, throughcmux notifywhen cmux is installed, else through Notification Center viaosascript(display notification), with the same fixes as the Troubleshooting note below; - puts a disabled
Icon hidden since 19:14 (menu bar full or behind the notch)line at the top of the menu, for anyone who reaches the menu through a menu bar manager.
It warns again only after the item has been visible and disappears again, so a 3-second refresh loop never repeats it. The probe is skipped while the menu is open, because a menu bar manager reveals hidden items for exactly that long and the item would otherwise flap. A reading the window server has not laid out yet (zero width, as on the very first refresh after launch) decides nothing.
Daemon states
The first menu line reports one of these, derived only from the file and the clock:
| State | Condition | Menu line |
|---|---|---|
| running | now - at <= 2.5 x intervalMs and stoppedAt is null |
Daemon running (last poll 12s ago) |
| stale | file exists, stoppedAt is null, last poll older than 2.5 x the interval |
Daemon not responding (last poll 4m ago) |
| stopped | stoppedAt is set (the daemon shut down cleanly) |
Daemon stopped at 19:20 |
| missing | no file at the path | Daemon not running / Start it with: agent-watchdog watch (or agent-watchdog install) |
| unreadable | file exists but is not valid status JSON | Status file unreadable / the decoding error |
intervalMs falls back to 30000 when the file does not carry it. Unknown JSON
fields and unknown enum values (level, lifecycle, source, kind,
location) are tolerated so a newer daemon never breaks an older app.
Handoffs in the status file
Each session may carry a handoff field: null (or absent, in files from an
older daemon) when none has been written, otherwise
{ "path": "/abs/handoff-<sessionId>.md", "writtenAt": "<ISO 8601>", "location": "repo" | "scratchpad" }.
A handoff object without a path is treated as no handoff. The daemon also
records an event of kind handoff with level info when it writes one.
Menu
Rebuilt on every refresh, top to bottom:
- Config notices (disabled), only when the daemon reports them and is
running:
Config file ignored: <message>when the daemon runs on flags and defaults because its config file failed to load (hovering shows the message and the path), thenRestart daemon to apply settingswhen the config file changed on disk after the daemon read it (configFile.changedSinceLoad). The app repeats what the daemon published; it never judges the file itself. Neither line shows for a stopped daemon, and the restart line needs a running one (a stale document’s flag is history). - Hidden-item line (disabled), only while the status item is hidden:
Icon hidden since 19:14 (menu bar full or behind the notch). - Daemon state (disabled) and a second disabled line with the config
summary:
warn 35% · critical 75% · messaging off, with· dry-runappended when the daemon runs in dry-run mode. For the missing and unreadable states the second line is the hint or error shown in the table above. - Sessions, one item each, sorted by context percentage descending:
feat-watchdog · fable-5-1 · 23% · running · cache expires in 41m. The model drops itsclaude-prefix; the cache segment is when the daemon expects the prompt cache to lapse (cache expired 12m agoonce it has), computed as last activity plus the cache TTL rather than observed from the API, and is omitted when the daemon does not know it. Sessions above the warn line are prefixed[warn]or[critical]. Hovering shows tokens used of the window, the working directory, and the session id, plusHandoff written 12m ago: /abs/path/handoff-<id>.mdwhen the daemon reported a handoff; the row itself then ends in· handoff. Clicking a session focuses it in cmux: it runscmux select-workspace --workspace <workspaceId>and then, when the session has a surface id,cmux focus-panel --panel <surfaceId> --workspace <workspaceId>(both withCMUX_QUIET=1). Sessions without a workspace id (transcript-scan sessions) are shown disabled. When the daemon is not running the list is headedSessions from the last poll. With no sessions it readsNo sessionswhile the daemon is running andNo session dataotherwise. - Handoffs (disabled header) followed by one item per session with a
handoff, newest handoff first:
Open handoff · feat-x (12m ago). Clicking opens the file with its default app; Option-click reveals it in Finder. When the file is no longer on disk (checked each time the menu is rebuilt, so at every open) the item is disabled and readsOpen handoff · feat-x (12m ago) (missing). Hovering shows the path. With no handoffs the section readsNo handoffs. - Recent events (disabled header) followed by up to five events, newest
first:
19:14 [warn] cmux-remote: Context warning: 41% of the window is …, truncated to 70 characters, with(dry-run)appended for dry-run events. Handoff events are tagged[handoff]in place of their level (alwaysinfo):19:12 [handoff] feat-x: handoff written at /abs/path/…; the session name the daemon puts at the start of that text is dropped so it is not shown twice. Hovering shows the full text.No events yetwhen the file has none. - Actions:
Refresh: reread the file now.Settings...: open the settings window (see below). Cmd-, does the same while the window is key.Icon only: toggle the stored icon-only preference (checkmark when on); disabled, with the reason in its title, whenAGENT_WATCHDOG_MENUBAR_COMPACTis set.Start daemon/Restart daemon/Stop daemon, depending on state, plusRestart daemon to apply settingsaboveStop daemonwhile the daemon is running and reportschangedSinceLoad. Start and both restarts runlaunchctl kickstart -k gui/<uid>/com.wholeclove.agent-watchdogand, if that fails (label not loaded),launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist. Stop runslaunchctl bootout gui/<uid>/com.wholeclove.agent-watchdog. When the plist is not installed a disabledDaemon not installed (run: agent-watchdog install)item is shown instead.Open logs: opens~/Library/Logs/agent-watchdog/in Finder (disabled until the directory exists).Open status file: opens the status file with its default app (disabled until the file exists).Quit.
Every external command is launched with an executable path and an argument
array, never a shell string, off the main thread; the menu refreshes when the
command finishes. cmux is resolved from /opt/homebrew/bin/cmux,
/usr/local/bin/cmux, then PATH.
Settings window
Settings... in the menu opens one window (a second open brings the same
window to the front) that shows every daemon setting, grouped the way the
daemon’s settings metadata groups them. Nothing in the window is hand-built
from the option list: it runs agent-watchdog config schema, decodes the
x-groups of that document, and renders one row per setting from its control
kind. Adding an option to apps/watchdog/src/cli/options.ts and regenerating
the schema adds it to the window with no Swift change. Per control:
| Control | Row |
|---|---|
| toggle | switch |
| number, percent | numeric field with a stepper, bounded by the schema’s min, max, integer |
| duration, path | text field (path rows add a Choose... button) |
| text (multiline) | multi-line editor; the [agent-watchdog] prefix is a fixed label above it |
enum (multiple) |
one checkbox per allowed value, written in the schema’s order |
| list | one text field per item with remove buttons, plus Add |
| anything else | plain text field holding the raw JSON, so a newer daemon’s control still shows |
Each row shows the help text from the metadata, whether the value comes from
the config file or is the built-in default, an edited mark once changed, a
Reset to default button that removes the key rather than writing the default
value, and its validation error inline. Validation mirrors the schema (type,
range, whole numbers, the duration syntax, enum members, the required message
prefix); the cross-field rule (critical above warn) stays with the daemon’s
config validate. Clearing a text field or emptying a list also removes the
key.
Save. Save (Cmd-S; Return never saves, so a newline inside a message
editor stays a newline) is enabled once something is edited and every local
check passes. It writes the form to <config path>.<pid>.tmp next to the
config file, runs agent-watchdog config validate <that file> --json through
the daemon CLI, and only then renames the candidate over the config file, so
the daemon never sees a partial or invalid file. When the daemon rejects the
candidate, every field its error names is highlighted with the daemon’s
message (the cross-field rule, critical above warn, names both), messages that
name no field appear above the form, the candidate is removed, and the config
file is left as it was. After a successful save the footer reads Saved at 19:14. Restart the daemon to apply. and, when the launch agent is installed,
a sheet offers Restart Daemon (the same launchctl kickstart -k the menu’s
restart uses) or Later. Restart daemon is also always available in the
footer while the daemon is installed. Cancel (or Cmd-W) closes the window
without writing.
What the daemon is running with. Every row shows Daemon is running with: <value> from the status document’s config.settings (durations as
45s, lists joined with commas, not set for an optional setting without a
value) so the saved value and the live value can be compared. Rows whose
configSources entry is cli are disabled with the note “Set by a
command-line flag in the launch agent; reinstall the daemon to manage it
here”: an older install baked the flag into the plist, and a flag always wins
over the file. Above the form, a red banner repeats the daemon’s configError
while it runs on flags and defaults because the file failed to load, and an
orange banner with a Restart daemon button appears while the daemon reports
changedSinceLoad (the file on disk is newer than the one it read). Both
banners come from the status document, which the app rereads every 3 seconds,
so a restart clears them within a poll.
Where the file lives. The window edits, in order: the file named by
AGENT_WATCHDOG_CONFIG_FILE; the file the daemon’s status document names in
configFile.path (so a daemon started with --config other.json is edited
where it reads); else ~/.config/agent-watchdog/config.json. The footer shows
the path in use. When the file does not exist the daemon’s defaults are shown
and the first save creates the file and its directory.
Finding the daemon CLI. The window needs the daemon’s command line for
config schema and config validate. It takes, in order: the executable named by
AGENT_WATCHDOG_CLI in the environment; the node and script paths from
ProgramArguments in the launch agent plist
(~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist, the file
agent-watchdog install writes); then agent-watchdog in
/opt/homebrew/bin, /usr/local/bin, or PATH. When none exists, or the
command fails (for example a daemon built before config schema existed), the
window shows the exact command that failed and its stderr, with a Try again
button, instead of a form.
Three environment variables exist for checking the window without clicking:
AGENT_WATCHDOG_MENUBAR_OPEN_SETTINGS=1 opens it at launch,
AGENT_WATCHDOG_CONFIG_FILE=/some/config.json points it at a hand-written
file (the footer shows the path in use), and
AGENT_WATCHDOG_MENUBAR_SAVE_ON_OPEN=1 sets warn-context to 45 once the
window has loaded and runs the whole save pipeline (validate, write) with the
restart offer suppressed, logging the outcome to stderr; it is refused unless
AGENT_WATCHDOG_CONFIG_FILE is set, so it can never touch the real config
file. None is meant for normal use.
The app keeps an invisible main menu (it has no menu bar of its own) so that the standard Edit shortcuts work inside the window’s text fields and Cmd-, and Cmd-W reach the settings window.
Permissions
The app needs no macOS permissions. It draws a status item, reads files under
the home directory, opens handoff files through the default app or Finder, and
runs launchctl, cmux, the daemon CLI (config schema and config validate --json, for the settings window), and (for the hidden-item warning
when cmux is absent) osascript as
the current user. It does not use
the notification framework itself, Accessibility, or screen recording; the
window-server query for its own status item needs no permission.
Troubleshooting
The icon is missing on a notched MacBook. When the menu bar is full, macOS hides the newest status items behind the notch without any warning of its own. The app is running; there is simply no room. The app notices (see Hidden-item warning) and posts one notification with the fixes:
- Switch to icon only:
AGENT_WATCHDOG_MENUBAR_COMPACT=1in the environment ordefaults write agent-watchdog-menubar compact -bool true, then restart or wait one refresh. The full item needs about 76 pt of bar; icon only needs about 37 pt. - Free a slot by quitting another menu bar app or by using a menu bar manager. Third-party status items cannot be dragged off the bar, only reordered with Command-drag, and reordering can push a different item under the notch.
Measured on the machine this was diagnosed on (macOS 15.7, display 1496 pt wide, notch from 668 to 828 pt, leftmost visible item at 857 pt): the 76 pt item was placed at x=781, under the notch, with no on-screen record; the 37 pt icon-only item did not fit the 29 pt gap that bar had left either, so on that bar only freeing a slot helps. Icon only needs roughly its own 37 pt of free bar; the full item needs about 76 pt.
Confirm the app is alive with
launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog-menubar (when
installed via agent-watchdog install --menubar-bin) or
pgrep -fl agent-watchdog-menubar, and look in its stderr for the
status item is hidden line.
Layout
Sources/MenubarCore: Foundation-only library. Status file models and decoder (ISO-8601 with fractional seconds), daemon-state derivation, worst session selection, handoff rows and their enabled state (with an injected file-exists check), hidden-item transitions and icon-only precedence, all string formatting, the settings schema decoder, the settings form model (values, dirty state, validation, serialization, flag-locked rows, daemon errors), the save pipeline (candidate file,config validate --jsonparsing, atomic rename) with injected I/O, the config notices the menu and window show, and the launch agent plist parser that finds the daemon CLI. This is what the tests cover.Sources/MenubarApp: the AppKit executable (status item, menu, command runner, window-server visibility probe, notifier, main menu) plus the SwiftUI settings window and its window controller. Not unit tested.Tests/MenubarCoreTests: XCTest suite for the core library.
CI
This workspace is not covered by CI. Its package.json scripts run
swift build / swift test only on Darwin and print skip: macOS only
elsewhere, so the root yarn build, yarn test, yarn lint, and
yarn typecheck stay green on Linux runners without building the app. Run the
build and tests locally on a Mac before merging changes here.