agent-watchdog

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:

  1. The environment: AGENT_WATCHDOG_MENUBAR_COMPACT=1 forces icon only, AGENT_WATCHDOG_MENUBAR_COMPACT=0 forces 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 by agent-watchdog install --menubar-bin.

  2. 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, through cmux notify when cmux is installed, else through Notification Center via osascript (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.

Rebuilt on every refresh, top to bottom:

  1. 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), then Restart daemon to apply settings when 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).
  2. Hidden-item line (disabled), only while the status item is hidden: Icon hidden since 19:14 (menu bar full or behind the notch).
  3. Daemon state (disabled) and a second disabled line with the config summary: warn 35% · critical 75% · messaging off, with · dry-run appended 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.
  4. Sessions, one item each, sorted by context percentage descending: feat-watchdog · fable-5-1 · 23% · running · cache expires in 41m. The model drops its claude- prefix; the cache segment is when the daemon expects the prompt cache to lapse (cache expired 12m ago once 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, plus Handoff written 12m ago: /abs/path/handoff-<id>.md when the daemon reported a handoff; the row itself then ends in · handoff. Clicking a session focuses it in cmux: it runs cmux select-workspace --workspace <workspaceId> and then, when the session has a surface id, cmux focus-panel --panel <surfaceId> --workspace <workspaceId> (both with CMUX_QUIET=1). Sessions without a workspace id (transcript-scan sessions) are shown disabled. When the daemon is not running the list is headed Sessions from the last poll. With no sessions it reads No sessions while the daemon is running and No session data otherwise.
  5. 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 reads Open handoff · feat-x (12m ago) (missing). Hovering shows the path. With no handoffs the section reads No handoffs.
  6. 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 (always info): 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 yet when the file has none.
  7. 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, when AGENT_WATCHDOG_MENUBAR_COMPACT is set.
    • Start daemon / Restart daemon / Stop daemon, depending on state, plus Restart daemon to apply settings above Stop daemon while the daemon is running and reports changedSinceLoad. Start and both restarts run launchctl kickstart -k gui/<uid>/com.wholeclove.agent-watchdog and, if that fails (label not loaded), launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist. Stop runs launchctl bootout gui/<uid>/com.wholeclove.agent-watchdog. When the plist is not installed a disabled Daemon 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=1 in the environment or defaults 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 --json parsing, 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.