agent-watchdog

Usage and configuration

This guide takes you from a fresh clone, or a downloaded prebuilt build, on a Mac to a running watchdog daemon and menu bar app. It covers the configuration file, every flag, the alert rules, message injection, the status file, the settings window, and what to check when something is off. The daemon’s design notes live in apps/watchdog/README.md; the menu bar app’s in apps/menubar/README.md. This guide and both of those are also rendered on the site.

What it does

agent-watchdog polls the coding-agent sessions running in your cmux terminals (Claude Code today) and estimates, from each session’s own transcript, how much of its context window is in use and when its prompt cache will expire. It notifies you when a session crosses a warning threshold (35% by default), a critical threshold (75%), or sits idle with its cache about to expire. It also keeps a context-percentage pill in the cmux sidebar and writes a status file that the menu bar app renders. Optionally, and only when you turn on message-agents (the --message-agents flag, or "message-agents": true in the config file), it can type a short [agent-watchdog]-prefixed message into the agent’s terminal asking it to stop or write a handoff. That is off by default and guarded by safety checks.

Prerequisites

  • macOS. The daemon shells out to launchctl, ps, and osascript; the menu bar app is AppKit.
  • cmux, with its Claude Code hooks active. The daemon reads cmux’s session store at ~/.cmuxterm/claude-hook-sessions.json to find sessions and their terminal surfaces. Without cmux it still finds sessions by scanning transcripts, but it can only notify through macOS notifications and cannot message an agent.
  • Node 26 or newer (.nvmrc pins 26; root package.json requires >=26). With nvm installed, run nvm use in the repo.
  • Yarn 4.11 is vendored in the repo (.yarnrc.yml points yarnPath at .yarn/releases/yarn-4.11.0.cjs). Any yarn launcher on your PATH, including the shim corepack provides, delegates to it. If you have no yarn command at all, substitute node .yarn/releases/yarn-4.11.0.cjs for yarn in every command below.
  • For the menu bar app only: the Xcode command line tools with Swift 5.9 or later. The package targets macOS 13.
xcode-select --install
swift --version

Download a prebuilt build

If you are on Apple silicon you can skip cloning and building entirely. Requirements: macOS 13 or newer, Apple silicon, cmux, and Node 26 or newer on your PATH (install.sh checks for it and tells you if it is missing or too old).

curl -fsSL https://agent-watchdog.pages.dev/downloads/agent-watchdog-darwin-arm64.tar.gz | tar -xz
./agent-watchdog/install.sh

install.sh strips the macOS quarantine flag from the unpacked tree, copies it to ~/.local/share/agent-watchdog, symlinks agent-watchdog and agent-watchdog-menubar into ~/.local/bin, and then runs agent-watchdog install to register the launch agent, the same install documented in “Install as a background service” below. Pass --no-install to stop after linking, and put flags for agent-watchdog install after --:

./agent-watchdog/install.sh --no-install
./agent-watchdog/install.sh -- --message-agents --on-critical notify,stop

If you downloaded the tarball with a browser instead of curl, macOS quarantines it; install.sh clears that automatically, but if you skip install.sh and run the binaries directly, clear it yourself first:

xattr -dr com.apple.quarantine agent-watchdog

The launch agent bakes the absolute path of whichever node ran install.sh (or agent-watchdog install) at install time. If that Node version is later removed, for example by running nvm uninstall after switching versions, the daemon stops starting until you re-run install with a Node that still exists; install.sh prints which node it used so you know which one to keep.

To upgrade, download the tarball again and run the same two lines. install migrates settings the same way for a downloaded build as for a from-source one (see “Older installs” under “Install as a launchd agent” in apps/watchdog/README.md).

Build from source

The rest of this guide applies whether you built from source or downloaded a prebuilt build; the difference is that a downloaded build already has agent-watchdog on your PATH via install.sh, in place of node apps/watchdog/dist/bin/agent-watchdog.js below.

From the repo root:

nvm use
yarn install
yarn build

yarn build compiles the daemon to apps/watchdog/dist/. The CLI entry point is apps/watchdog/dist/bin/agent-watchdog.js. The daemon has no runtime dependencies.

The menu bar app is a Swift package outside the TypeScript build graph, so build it separately:

yarn workspace @agent-watchdog/menubar build

That produces apps/menubar/.build/release/agent-watchdog-menubar, a single executable with no app bundle.

Until you run link (below), invoke the daemon as node apps/watchdog/dist/bin/agent-watchdog.js. After link, plain agent-watchdog works from any directory.

Run it once by hand

Start with status. It discovers sessions and prints a table, then exits. It never notifies, never types into a terminal, and never touches the state or status files.

node apps/watchdog/dist/bin/agent-watchdog.js status
PROVIDER  SESSION   DIR             MODEL                       CONTEXT  PCT  STATE       LAST  CACHE EXPIRES  SURFACE   HANDOFF
claude    415754c5  agent-watchdog  claude-fable-5-1  136,239/1,000,000  14%  running      18s         in 59m  01DFD726  repo
claude    5cad2314  cmux-remote     claude-opus-5     127,597/1,000,000  13%  needsInput    1m         in 58m  C95A7102

How to read it:

Column Meaning
SESSION First 8 characters of the session id. Use it with --session.
DIR Basename of the session’s working directory.
CONTEXT Tokens in context over the estimated window for that model.
PCT Context usage. The thresholds compare against this number.
STATE Lifecycle from the cmux store: running, idle, needsInput, or unknown. Sessions found by transcript scan show unknown (scan).
LAST Time since the last assistant turn.
CACHE EXPIRES When the daemon expects the prompt cache to lapse: in 59m while it is ahead, 12m ago once it has passed. This is computed as last activity plus the cache TTL, not observed from the API.
SURFACE First 8 characters of the cmux surface id, or - for scanned sessions (they cannot be messaged).
HANDOFF Where the session’s handoff-<sessionId>.md was found: repo (repo root) or scratch (session scratchpad). Blank when there is none.

Rows are sorted by percentage, highest first. With no sessions the output is No active sessions found.

Next, run one poll of the real daemon logic in dry-run mode. This evaluates the rules and logs what it would do, but sends no notifications and types nothing.

node apps/watchdog/dist/bin/agent-watchdog.js watch --once --dry-run --verbose \
  --state-file /tmp/docs-scratch-state.json
2026-09-11T19:08:09.079Z debug using cmux at /opt/homebrew/bin/cmux
2026-09-11T19:08:09.080Z info  watching every 30s (warn 35%, critical 75%, cache lead 10m, messaging off, dry-run)
2026-09-11T19:08:09.088Z debug claude 415754c5 14.0% running store actions=0
2026-09-11T19:08:09.088Z debug claude 5cad2314 12.8% needsInput store actions=0
2026-09-11T19:08:09.089Z info  stopped

Every log line is <timestamp> <level> <message>. info and debug go to stdout; warn and error go to stderr. --verbose adds the debug lines, including one per session with its percentage, lifecycle, discovery source, and how many actions the rules produced. A dry-run action shows up as dry-run notify ... or dry-run <kind> message to surface ....

Why the extra flag: watch persists alert state even with --dry-run and --once, so a hand run against the default state file would mutate the installed daemon’s alert state. Point hand runs at a scratch state file. A one-shot run is otherwise self-contained: --once never writes the status file unless you pass --status-file <path> explicitly, and neither --once nor --dry-run touches the cmux sidebar pill, so the menu bar app and the sidebar keep showing the installed daemon.

A hand run reads the same config file as the installed daemon (~/.config/agent-watchdog/config.json, see “Configuration file”), so it runs with your saved thresholds and messaging settings unless a flag overrides them. Add --no-config to run on the built-in defaults alone.

A long-running hand run (no --once) is different. It would write the default status file and overwrite the daemon’s sidebar pill every poll, so give it --no-status-file (or its own --status-file) and --no-sidebar as well.

For machine-readable output, add --json. Each poll becomes one JSON line on stdout and all log lines move to stderr. The sample below is pretty-printed for reading; the real output is a single line per poll:

node apps/watchdog/dist/bin/agent-watchdog.js watch --once --json --dry-run \
  --state-file /tmp/docs-scratch-state.json 2>/dev/null
{
  "at": "2026-09-11T19:08:09.134Z",
  "sessions": [
    {
      "provider": "claude",
      "sessionId": "415754c5-...",
      "cwd": "/Users/me/projects/agent-watchdog",
      "model": "claude-fable-5-1",
      "contextTokens": 140152,
      "contextWindow": 1000000,
      "contextPct": 14.0152,
      "lastActivityAt": "2026-09-11T19:07:35.035Z",
      "cacheTtlMs": 3600000,
      "cacheExpiresAt": "2026-09-11T20:07:35.035Z",
      "lifecycle": "running",
      "source": "store",
      "surfaceId": "01DFD726-...",
      "workspaceId": "749DB8E5-...",
      "pid": 78542,
      "pidStartSeconds": 1789079591,
      "executablePath": "/Users/me/.local/bin/claude",
      "currentForSurface": true,
      "transcriptPath": "/Users/me/.claude/projects/.../415754c5-....jsonl",
      "handoff": null,
      "actions": [],
      "injected": []
    }
  ]
}

actions lists what the rules decided for that session this poll (notify, send-message, log, or handoff-written entries) and injected lists the message kinds that were actually typed. handoff is the session’s handoff file when one exists (see “Handoff files” under “Alerts and message injection”), in the same { path, writtenAt, location } shape as the status file. This per-poll document is the raw internal snapshot, with process details included. It is not the status-file schema; see “Status file and JSON output” for the difference.

Install as a background service

install writes a launchd agent that runs watch at login and keeps it alive, then loads it. Build first, then install:

node apps/watchdog/dist/bin/agent-watchdog.js install

Settings flags given to install are not baked into the launch agent. They go into the config file, ~/.config/agent-watchdog/config.json (see “Configuration file”), which the daemon reads at startup. So this:

node apps/watchdog/dist/bin/agent-watchdog.js install --message-agents --on-critical notify,handoff

writes "message-agents": true and "on-critical": ["notify", "handoff"] into the file and installs a plist whose arguments are just watch and --tolerate-config-error. You do not need to pass settings to install at all: write the file by hand, or use the settings window, before or after installing.

What install does:

  1. Writes the settings flags it was given into the config file. Only those keys are written; keys already in the file are kept. When the file is created it gets a $schema pointer to the checked-in apps/watchdog/schema/settings.schema.json in a source checkout, or the shipped schema/settings.schema.json next to the unpacked tree for a downloaded install. The merged file is checked the way config validate checks it, and if it would be invalid (say --critical-context 50 over a file that sets warn-context to 60), install prints the error, writes nothing, and exits with code 2.
  2. Writes ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist. Its ProgramArguments are the absolute path of the node binary that ran install, the absolute path of apps/watchdog/dist/bin/agent-watchdog.js, watch, then only the CLI-only flags that apply to watch (--config <path> when you gave one, --no-config, --no-status-file, --json), and always --tolerate-config-error, so a bad config file cannot take the daemon down (see “Configuration file”). RunAtLoad and KeepAlive are on and ProcessType is Background.
  3. Sets the agent’s PATH to the directory of the resolved cmux binary, the directory of that node binary, /opt/homebrew/bin, /usr/local/bin, /usr/bin, and /bin.
  4. Creates ~/Library/Logs/agent-watchdog/ and points the agent’s stdout and stderr at stdout.log and stderr.log there.
  5. Boots out any previously loaded copy and waits until launchctl print no longer knows the service, polling every 250 ms for up to 5 s (a service that lingers past that only earns a warning). It then loads the new plist with launchctl bootstrap gui/<uid>, retrying up to five more times with 500 ms gaps while launchd is still busy with the old instance, falls back to launchctl load -w if every attempt fails, and finally verifies the job with launchctl print. If launchd did not keep the job, install prints an is not loaded message and exits with code 1 instead of claiming success.

--no-config together with a settings flag is an error (exit code 2): with no file the setting would have nowhere to go, and install never bakes one. --once, --print, --dir, --help, and --version are never baked.

Changing a setting later does not mean reinstalling. Edit the file (or save it from the settings window) and restart the daemon:

launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog

Run install again only when the launch agent itself has to change: a new node path, a moved cmux binary (the plist’s PATH is fixed at install time), a CLI-only flag such as --no-status-file, or --menubar-bin.

Older installs. Before the config file existed, install baked every flag into the plist. When a plist is already installed, install reads it before replacing it and looks at the settings baked after watch:

  • With no config file yet, those settings are moved into the new file, one notice each (moved --warn-context 60 from the launch agent into ~/.config/agent-watchdog/config.json). A flag given to this install wins over a baked value of the same setting (replaced --warn-context 60 from the launch agent with --warn-context 40 in ...).
  • With a config file already present, the baked settings are dropped with a notice (dropped --warn-context 60: baked by an older install, and ... already manages settings) and the file is left alone, so a reinstall never silently changes settings the file manages. Set them in the file if you still want them.
  • A baked flag this build no longer knows is skipped with a notice instead of failing the install.

A plist from an older install keeps working until you reinstall, because a flag always wins over the file. Until then the settings window shows those settings locked (see “Settings window” under “Menu bar app”).

To preview everything without writing or loading anything:

node apps/watchdog/dist/bin/agent-watchdog.js install --print --message-agents

--print prints the plist, then any migration notices prefixed with would have, then, when the config file would change, would write <path>: followed by the file’s new contents.

To also start the menu bar app at login, build it and pass its binary:

yarn workspace @agent-watchdog/menubar build
node apps/watchdog/dist/bin/agent-watchdog.js install \
  --menubar-bin apps/menubar/.build/release/agent-watchdog-menubar

--menubar-bin must name an existing executable file, or install exits with code 2 and a hint. A relative path is resolved against the current directory at install time and the absolute path is written to the plist. This adds a second agent, ~/Library/LaunchAgents/com.wholeclove.agent-watchdog-menubar.plist, with RunAtLoad on, KeepAlive off (so quitting the app sticks until next login), ProcessType Interactive, the same PATH, and logs at menubar.stdout.log and menubar.stderr.log in the same log directory. install --print prints both plists.

Restarting, stopping, and inspecting the daemon with launchctl:

# restart (also picks up a new build; the plist points at dist/, so no reinstall)
launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog

# stop until next login or the next bootstrap
launchctl bootout gui/$(id -u)/com.wholeclove.agent-watchdog

# start again after a bootout
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist

# show state, pid, and last exit status
launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog

The same commands work for the menu bar agent with the label com.wholeclove.agent-watchdog-menubar.

To remove both agents:

node apps/watchdog/dist/bin/agent-watchdog.js uninstall

uninstall boots out the daemon agent, waits for launchd to report it gone the same way install does (250 ms polls, up to 5 s), and deletes its plist, then does the same for the menu bar agent when its plist exists. Because both commands wait, uninstall && install back to back is safe. The config file is kept (uninstall prints kept <path>; delete it by hand to reset the settings when one exists), so a reinstall comes back with the same settings.

For day-to-day use, link puts the command on your PATH without installing a service:

node apps/watchdog/dist/bin/agent-watchdog.js link
agent-watchdog status

link marks the built script executable and symlinks it as ~/.local/bin/agent-watchdog (use --dir for another directory). It replaces an existing symlink but never touches a real file, and it warns when the directory is not on your PATH. With --menubar-bin <path> it also symlinks agent-watchdog-menubar next to it. link does not need to be repeated after a rebuild.

The rest of this guide uses the linked agent-watchdog command.

Configuration file

The daemon’s settings live in ~/.config/agent-watchdog/config.json. Every command reads it at startup (watch, status, install, uninstall, and link); a missing file is fine and means built-in defaults. The file is one JSON object whose keys are the option names exactly as they appear on the command line, without the leading dashes:

{
  "$schema": "/path/to/agent-watchdog/apps/watchdog/schema/settings.schema.json",
  "message-agents": true,
  "on-critical": ["notify", "handoff"],
  "on-cache-expiry": ["notify", "handoff"]
}

That example turns on handoff messaging: at the critical threshold and before an idle session loses its prompt cache, the daemon notifies you and asks the agent to write a handoff. Save it, then restart the daemon so it reads the file again:

launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog

Value shapes follow how each setting is edited. The placeholder in each flag’s name below (<duration>, <pct>, <tokens>, <path>, <text>, <list>) says which kind it is; flags with no placeholder are toggles.

Kind JSON value Example
toggle (verbose, dry-run, sidebar, scan-transcripts, message-agents) boolean; false is the --no- form "sidebar": false
duration string in the flag syntax "cache-lead": "15m"
percent, number number (a numeric string is accepted too) "warn-context": 40
path, text string "cmux-path": "/opt/homebrew/bin/cmux"
list (session, cwd, model-window) array of strings "cwd": ["~/projects/a", "~/projects/b"]
action set (on-critical, on-cache-expiry) array of the allowed values, instead of the flag’s comma list "on-critical": ["notify", "handoff"]
message template (stop-message, handoff-message, cache-handoff-message) string; the sent message always starts with [agent-watchdog] "stop-message": "[agent-watchdog] Stop at a safe point."

A message template should start with [agent-watchdog]. The settings window shows that prefix as a fixed label and writes it for you; a hand-edited template without it is accepted and the daemon prepends the marker (and flattens newlines) before typing, so the text the agent sees always carries it.

$schema is optional and ignored by the daemon. Point it at the checked-in apps/watchdog/schema/settings.schema.json (the shipped schema/settings.schema.json next to a downloaded install, or at the output of agent-watchdog config schema) and an editor with JSON Schema support validates keys, types, and ranges as you type. install writes it with an absolute path when it creates the file.

Precedence is flag > file > built-in default. A flag on the command line always wins over the file, for a hand run and for the installed daemon alike; the file only fills in settings no flag set. The status file reports where every running value came from in configSources (see “Status file and JSON output”).

CLI-only flags cannot live in the file. They shape one invocation rather than the daemon’s behaviour: --help, --version, --once, --json, --no-status-file, --tolerate-config-error, --config, --no-config, --print, --dir, and --menubar-bin. A file that names one is rejected (--once is a command-line flag and cannot be set in the config file), as is a file with a key that is not a setting at all (unknown setting "warn-contxt"), so a typo cannot silently do nothing. Everything else in the flag tables is file-eligible; the “Where” column marks the two kinds.

Other files. --config <path> reads another file (a hand run against a scratch file, or a second daemon with its own settings); install --config <path> bakes that path into the launch agent so the daemon reads the file install wrote to. --no-config reads no file at all, so the run uses flags and built-in defaults only; install --no-config bakes it, giving a daemon that never reads a config file (and refuses settings flags, since they would have nowhere to go).

Checking a file. agent-watchdog config validate [<path>] loads the file (the --config path, or the default, when no path is given) through the same loader and the same checks as the daemon and prints ok with exit code 0, or the error with exit code 2. A missing file is valid. --json prints {"ok":true} or {"ok":false,"errors":[{"options":[...],"message":"..."}]}, where options names the settings the error is about (the cross-field check names both critical-context and warn-context). Errors are reported one at a time. agent-watchdog config schema prints the JSON Schema (draft 2020-12) the file is checked against, with a default, description, type, and range per setting; config alone, or an unknown action, prints a usage line and exits with code 2. Neither config command reads the file for its own settings, so both work while the file is broken.

agent-watchdog config validate
agent-watchdog config validate ~/scratch/config.json --json
agent-watchdog config schema | head -40

A bad file (not JSON, an unknown key, a wrong type, a value out of range, the critical threshold at or below the warning one) is treated like a bad flag by a foreground run: watch, status, install, uninstall, and link print config file <path>: <error> and exit with code 2, so the mistake stops you at the terminal. --no-config is the escape hatch when you need one of them to run regardless.

The installed daemon is the exception, because install always bakes --tolerate-config-error into the launch agent: exiting would make launchd’s KeepAlive restart it into a loop with nothing watching your sessions. With that flag the daemon starts on its flags and built-in defaults, logs the failure at error level (config file ignored, running on flags and defaults: ...), sends one notification titled config file ignored with the error as its body, and publishes the error as configError in every status document until it is restarted with a fixed file. The menu bar app shows it as the first line of its menu (Config file ignored: <message>) and as a red banner in the settings window. A hand watch can pass the flag too.

Configuration reference

Every setting is a command-line flag, and every file-eligible setting is also a key in the config file under the same name (see “Configuration file”). Values are validated up front, and a bad value prints a message and exits with code 2, whether it came from a flag or from the file.

Value syntax:

  • Durations take a number with a unit: ms, s, m, h, or d. Units can be chained (2h30m) and decimals are accepted (1.5h). A bare number is seconds (90 is 90 seconds). Durations must be greater than zero. In the file they are strings: "cache-lead": "15m".
  • Percentages are numbers from 0 to 100. Thresholds fire at or above the value. --critical-context must be greater than --warn-context.
  • Repeatable flags (--session, --cwd, --model-window) can be given as many times as you like. In the file they are arrays of strings.
  • Negation. Boolean flags that default to on are turned off with a --no- prefix: --no-sidebar and --no-scan-transcripts. In the file the same settings are plain booleans ("sidebar": false). --no-status-file is its own flag, because --status-file takes a path, and it is CLI-only.
  • Where. flag or file means the setting can be given as a flag or as a key in the config file, and appears in the settings window; flag only means it exists only on the command line.

Commands

Command What it does
watch (default) Poll sessions and alert until stopped (or once, with --once).
status Print a one-shot table of sessions and exit; --json prints one status document instead.
install Write settings flags into the config file, install the launchd agent running watch with the CLI-only flags, and load it. --menubar-bin adds the menu bar app.
uninstall Unload and remove the launchd agents. The config file is kept.
link Symlink the built binary into ~/.local/bin (or --dir); --menubar-bin links the menu bar app too.
config schema Print the JSON Schema for the config file and the settings window.
config validate [<path>] Check a config file (default: the --config path or ~/.config/agent-watchdog/config.json); exit 0 with ok, or 2 with the error. --json for a JSON result.

Flags for all commands

Flag Default Where Meaning
-h, --help flag only Print the help text and exit.
-V, --version flag only Print the version and exit.
-v, --verbose off flag or file Add debug log lines (one per session per poll, wake-loop deferrals, cmux path).
--cmux-path <path> search flag or file Path to the cmux binary. By default the daemon searches PATH, then /opt/homebrew/bin, /usr/local/bin, /usr/bin, and /bin.
--state-file <path> ~/.local/state/agent-watchdog/state.json flag or file Where per-session alert state is persisted, so restarts do not re-alert.
--config <path> ~/.config/agent-watchdog/config.json flag only Config file to read settings from. A flag always wins over the file.
--no-config off flag only Do not read any config file.
--scan-transcripts on flag or file Also discover sessions by scanning ~/.claude/projects/*/*.jsonl. Turn off with --no-scan-transcripts.
--stale-after <duration> 6h flag or file Ignore sessions with no activity for longer than this.
--idle-after <duration> 2m flag or file Treat a session as idle after this much inactivity (used by the cache-expiry rule).
--session <id-prefix> all flag or file Only sessions whose id starts with this prefix. Repeatable; values are OR-ed.
--cwd <path> all flag or file Only sessions whose working directory is this path or lives under it. Repeatable; values are OR-ed. ~ is expanded.
--warn-context <pct> 35 flag or file Notify when context usage reaches this percentage.
--critical-context <pct> 75 flag or file Critical threshold. Keep it well below the point where Claude Code auto-compacts (somewhere above 90%).
--cache-min-context <pct> 20 flag or file Cache-expiry alerts only apply at or above this context percentage.
--cache-lead <duration> 10m flag or file Alert when the prompt cache expires within this lead time.
--cache-ttl <duration> detect, else 1h flag or file Force the prompt-cache TTL instead of detecting it from the transcript.
--context-window <tokens> 200000 flag or file Context window assumed for model ids that match nothing else.
--model-window <model=tokens> none flag or file Context window for a model id, by exact match or longest prefix. Repeatable.
--message-agents off flag or file Master switch that allows typing messages into agent terminals.
--on-critical <list> notify flag or file Comma-separated actions at the critical threshold: any of notify, stop, handoff. An array in the file.
--on-cache-expiry <list> notify flag or file Comma-separated actions when the cache is about to expire: any of notify, handoff. An array in the file.
--stop-message <text> built in flag or file Custom stop message. Placeholders: {pct}, {minutes}, {model}, {sessionId}, {handoffFile}, {now}, {cwd}.
--handoff-message <text> built in flag or file Custom handoff message. Same placeholders.
--cache-handoff-message <text> built in flag or file Custom cache-expiry handoff message. Same placeholders.

Flags for watch

Flag Default Where Meaning
--interval <duration> 30s flag or file Poll interval.
--once off flag only Run one poll and exit.
--dry-run off flag or file Evaluate and log actions without notifying, typing, or touching the sidebar pill.
--json off flag only Emit one JSON line per poll on stdout; logs move to stderr. Also accepted by status and config validate (see their tables).
--sidebar on flag or file Keep a ctx NN% pill in the cmux sidebar per workspace, re-asserted every poll. Turn off with --no-sidebar. --once and --dry-run never set or clear it.
--status-file <path> ~/.local/state/agent-watchdog/status.json flag or file Write the status document here after every poll. The menu bar app reads it. With --once the file is written only when this flag is given explicitly (a path in the file does not count).
--no-status-file off flag only Do not write the status file.
--tolerate-config-error off flag only Keep running on flags and defaults when the config file is invalid, notify once, and publish the error in the status file instead of exiting with code 2. install always bakes it.

Flags for status

Flag Default Where Meaning
--json off flag only Print one status document (the status-file schema) instead of the table.

Flags for config

Flag Default Where Meaning
--json off flag only config validate: print { "ok": true } or { "ok": false, "errors": [...] } instead of a line.
Flag Default Where Applies to Meaning
--print off flag only install Print the plist(s), the migration notices, and the config file that would be written, instead of writing and loading anything.
--dir <path> ~/.local/bin flag only link Directory to place the agent-watchdog symlink in.
--menubar-bin <path> none flag only install, link The menu bar app binary. install adds a launchd agent for it; link symlinks it as agent-watchdog-menubar.

Scoping a run with --session and --cwd

Both flags filter sessions after discovery and apply to every command, including install (which writes them into the config file, so the daemon runs with them). Within one flag the values are OR-ed; when both flags are given a session must match both. Sessions outside the filter are not evaluated, not shown by status, and get no sidebar pill.

# one session, by the prefix shown in the SESSION column
agent-watchdog status --session 415754c5

# every session under one project tree, plus one other directory
agent-watchdog watch --cwd ~/projects/agent-watchdog --cwd ~/scratch/wd-test

Context window overrides

The window is an estimate. The daemon resolves it in this order: an exact --model-window match, the longest-prefix --model-window match, model ids containing [1m] or ending in -1m (1,000,000), a built-in table by model prefix (see apps/watchdog/README.md), then --context-window. If a session shows a suspicious percentage, or more than 100%, override the model shown in the MODEL column:

# exact id
agent-watchdog status --model-window claude-opus-5=200000

# prefix: every claude-fable- model
agent-watchdog status --model-window claude-fable-=500000

# both, plus a fallback for unknown ids
agent-watchdog watch --model-window claude-opus-5=200000 --model-window claude-fable-=500000 \
  --context-window 400000

Alerts and message injection

Every poll, each session is evaluated against three rules. A notification goes to cmux (cmux notify, targeted at the session’s surface) and falls back to a macOS notification through osascript when cmux is missing or the call fails.

Rule Fires when Notification title
Warning Context usage reaches --warn-context. Context warning
Critical Context usage reaches --critical-context. Marks the warning as fired too, so you get one alert, not two. Context critical
Cache expiry Usage is at or above --cache-min-context, the cache is still alive but expires within --cache-lead, and the session is idle (lifecycle idle, or no activity for --idle-after). Prompt cache expiring

A cache that has already expired never triggers the cache rule; at that point a handoff is no cheaper than continuing, and status simply shows how long ago it lapsed (12m ago).

Hysteresis and re-arming. Each rule fires once per crossing. The warning and critical rules re-arm when usage drops 5 points below their threshold, which happens after compaction or when the session starts a new context. The cache rule re-arms when the cache is refreshed (expiry moves back outside the lead time) or usage drops 5 points below --cache-min-context. The fired flags live in the state file, so a daemon restart does not re-alert. Sessions not seen for 24 hours are dropped from the state file, so a session that resumes after a day re-alerts once if it is still above a threshold.

Message injection is a second, separate layer. It needs three things:

  1. --message-agents, the master switch ("message-agents": true in the config file, or the toggle in the settings window). Without it nothing is ever typed and --on-critical or --on-cache-expiry values other than notify are ignored.
  2. An action list that includes a message: --on-critical notify,stop, --on-critical notify,handoff, or --on-cache-expiry notify,handoff (in the file, "on-critical": ["notify", "handoff"]). Drop notify from the list to skip the notification and only send the message. When --on-critical lists both stop and handoff, only the handoff is sent; it already asks the agent to stop.
  3. A session that came from the cmux store (a scanned session has no terminal surface, so it can only be notified).

The messages are single lines that always start with the literal marker [agent-watchdog]. The marker is prepended to a custom message that lacks it, and any newlines in a custom message are collapsed to spaces so the text cannot submit itself early. The defaults:

  • stop: [agent-watchdog] Context is at {pct}% of the window. Stop starting new work, finish the current step at a safe point, and wait for the user.
  • handoff: [agent-watchdog] Context is at {pct}% of the window. Write a handoff now to {handoffFile} at the repo root (or in this session's scratchpad directory if the repo must stay clean), then wait for the user. Structure it as: a header with written-at {now}, session {sessionId}, repo {cwd} plus its branch and commit; the current milestone or objective; links to the plans and references you are following (files under docs/plans, tickets, PRs, design docs); state; decisions made; open questions; next steps, most important first.
  • cache handoff: [agent-watchdog] This session is at {pct}% context and its prompt cache expires in about {minutes} minutes. Write a handoff now to {handoffFile} at the repo root (or in this session's scratchpad directory if the repo must stay clean) so work can resume cheaply, then wait for the user. Structure it as: a header with written-at {now}, session {sessionId}, repo {cwd} plus its branch and commit; the current milestone or objective; links to the plans and references you are following (files under docs/plans, tickets, PRs, design docs); state; decisions made; open questions; next steps, most important first.

The placeholders:

Placeholder Value
{pct} Context usage as a whole-number percentage of the window.
{minutes} Whole minutes until the prompt cache expires (never negative).
{model} The model id.
{sessionId} The full session id.
{handoffFile} The per-session handoff filename, handoff-<sessionId>.md.
{now} When the daemon sent the message, as an ISO 8601 UTC timestamp (2026-09-12T14:03:27.000Z).
{cwd} The session’s working directory.

One file per session means several agents running at once never overwrite each other’s handoff. If the repo must stay clean, add handoff-*.md to its .gitignore, or have the agent write to the session’s scratchpad directory instead; the daemon looks in both places.

Handoff files

Every poll, the Claude provider checks whether handoff-<sessionId>.md exists, by stat alone (it never scans the transcript for write calls), at:

  • <cwd>/handoff-<sessionId>.md, the repo root; or
  • /private/tmp/claude-<uid>/<project-slug>/<sessionId>/scratchpad/handoff-<sessionId>.md, the session’s scratchpad. <project-slug> is the dash-encoded working directory that also names the session’s ~/.claude/projects/<slug> transcript directory; <uid> is the daemon’s user id.

When both exist, the newer mtime wins. The result shows up as the session’s handoff field in the status file and watch --json, and as the HANDOFF column of status. When a session goes from no file to a file, the daemon logs one info line and adds one handoff event to the status file’s events, which the menu bar app shows. The last seen path lives in the state file, so the event fires once per new file, not every poll, and a daemon restart does not repeat it. The check repeats every poll: delete the file and the field drops back to null; write it again and the event fires again. No notification is sent, and a missing handoff is never alerted on.

What a handoff contains. The default messages ask the agent for a fixed structure so the next session, or you, can resume without re-deriving anything. The header lines come straight from the {now}, {sessionId}, and {cwd} placeholders; the agent adds the branch and commit itself. Then the current milestone, links to the plans and references it is following (files under docs/plans, tickets, PRs, design docs), state, decisions, open questions, and next steps with the most important first:

# Handoff

- Written at: 2026-09-12T14:03:27.000Z
- Session: 415754c5-4c22-4d27-8557-2c0485e438bc
- Repo: /Users/me/projects/agent-watchdog (branch feat/handoff-template, commit 04d43cf)

## Milestone

The objective this session is working toward.

## Plans and references

- docs/plans/handoff-template.md
- PR #12, ticket ABC-42, the design doc being followed

## State

What is done, what is in flight, and what the working tree looks like.

## Decisions

Choices made and why.

## Open questions

Anything unresolved that the next session or the user must settle.

## Next steps

1. The most important step first.
2. Then the rest.

Safety checks before typing. All of these must hold, or the daemon only notifies, logs a skipped <kind> message: <reason> line, and tries again on a later poll:

  1. --message-agents is set.
  2. The session has a cmux surface (it came from the store).
  3. The store records a pid and its start time, and that pid is alive.
  4. One ps call confirms the pid’s identity: its start time matches the store to the second (so a reused pid is refused), its executable matches the one the store recorded (or, when the store has none, the command mentions claude), any --session-id in its arguments names this session, and it is in the foreground of its terminal (a suspended agent with a shell in front is refused).
  5. The session is the current session for its surface. Older sessions on the same surface are never messaged.
  6. The lifecycle is neither needsInput (a permission prompt may be open and typed text could answer it) nor unknown.

The checks run twice: once on the polled snapshot, and again on a fresh read of the session store immediately before typing, because a prompt can open while other sessions are being processed. Any change in surface, pid, or lifecycle between the two aborts the send. The text is typed with cmux send and submitted with cmux send-key Enter, each called with an argument array, so message text never passes through a shell. If the text was typed but Enter failed, the message still counts as sent, and you get a Message needs Enter notification asking you to press Enter in that terminal.

Wake-loop guard. Typing into an idle agent wakes it, which refreshes its cache and its activity time. So after the daemon injects any message into a session, it will not inject again until a genuine user turn appears in the transcript after that injection (a typed user message that does not start with [agent-watchdog]; tool results do not count). A send that is owed but blocked stays pending and goes out on the first poll after your next turn, if the condition still holds. Notifications are not affected by the guard.

Testing injection against a throwaway session

Any threshold low enough to force a message would hit every real session, so scope the run to one disposable session and one scratch state file.

  1. In a cmux terminal, create a scratch directory and start a plain claude session in it, so cmux registers the session in its store. Send it one prompt so the transcript carries token usage.

  2. Find its id:

    agent-watchdog status --cwd ~/scratch/wd-test
  3. Run one poll against that session only. The critical threshold must stay above the warning threshold, so lower both:

    agent-watchdog watch --once --session <SESSION prefix from step 2> \
      --message-agents --on-critical stop \
      --warn-context 0 --critical-context 1 \
      --state-file /tmp/wd-test-state.json

    With --once no status file is written and the sidebar pill is left alone, so only the scratch state file is needed.

    --cwd ~/scratch/wd-test works in place of --session when nothing else runs under that directory.

  4. Expect the stop message to appear in that terminal and be submitted. The log line sent stop message to surface <uuid> confirms it. A skipped line names the safety check that refused: needsInput if a permission prompt was open, or the foreground check if the agent was suspended behind a shell.

--dry-run stops before the send, so it cannot prove this path. Only a real run against a filtered session does.

Status file and JSON output

Path and timing. After every poll, including dry runs, the long-running daemon writes ~/.local/state/agent-watchdog/status.json (change it with --status-file, disable it with --no-status-file). The write is atomic: a .tmp file is written next to it and renamed into place, so a reader never sees a partial document. On a clean shutdown (SIGINT, SIGTERM) the file is written one last time with stoppedAt set. A failed poll writes nothing, so the file goes stale instead of claiming there are no sessions.

watch --once is the exception: it never writes the default status file, so a one-shot hand run cannot mark the installed daemon as stopped in the menu bar. It writes a status file only when --status-file <path> is passed explicitly, and that file then carries stoppedAt because the run ends after one poll.

Staleness rule. Readers treat the daemon as not running when now - at > 2.5 * intervalMs. A non-null stoppedAt means it stopped cleanly.

Two commands, one schema. agent-watchdog status --json prints exactly this document to stdout without touching any file; in that case daemonPid, startedAt, and stoppedAt are null, events is empty, and at is now. The menu bar app reads the same schema from the file. watch --json is different: it emits the raw per-poll snapshot described under “Run it once by hand”, including process details that the status file deliberately leaves out.

Schema version 1. Every timestamp is ISO-8601 with milliseconds and a Z suffix.

Field Type Meaning
version 1 Schema version.
daemonPid number or null Pid of the writing daemon.
startedAt timestamp or null When the daemon started.
at timestamp When this document was produced (end of the poll).
stoppedAt timestamp or null Set only by the final write of a clean shutdown.
intervalMs number Poll interval.
config object warnContextPct, criticalContextPct, cacheMinContextPct, cacheLeadMs, messageAgents, dryRun, onCritical[], onCacheExpiry[], and settings.
config.settings object Every file-eligible setting by its option name (warn-context, interval, …) with the value the daemon is running with, normalized: durations in milliseconds, repeatable flags and action sets as arrays, paths expanded, null for an optional setting that is not set (cmux-path, cache-ttl, status-file under --no-status-file). The settings window shows these as “Daemon is running with”.
configFile object or null { path, loadedAt, changedSinceLoad }: the config file the daemon consulted (even one that failed to load), when it read it (its start time), and whether the file’s mtime differs from the one read, or the file appeared or disappeared since, checked before every write. A restart clears it. Null under --no-config. From status --json, loadedAt is now and changedSinceLoad is false.
configSources object Per file-eligible option name, one of cli (a flag on the daemon’s command line), file (the config file), or default (built in). After an ignored config file every entry is cli or default.
configError object or null { message, path, at } while the daemon runs on flags and defaults because its config file was ignored under --tolerate-config-error; null when the file loaded or none was consulted.
sessions[] array One entry per session after --session and --cwd filtering. status --json sorts by percentage.
sessions[].provider string claude.
sessions[].sessionId, shortId string Full id and its first 8 characters.
sessions[].cwd, dirName string Working directory and its basename.
sessions[].model string Model id from the transcript.
sessions[].contextTokens, contextWindow number Tokens in context and the estimated window.
sessions[].contextPct number Usage percentage, rounded to one decimal.
sessions[].level ok, warn, critical Derived by the daemon from its own thresholds, so readers never recompute them.
sessions[].lifecycle string running, idle, needsInput, unknown.
sessions[].source store or scan How the session was discovered.
sessions[].lastActivityAt, cacheExpiresAt timestamp Last assistant activity and estimated cache expiry.
sessions[].cacheTtlMs number Detected or forced cache TTL.
sessions[].surfaceId, workspaceId string or null cmux ids; null for scanned sessions.
sessions[].currentForSurface boolean Whether the store says this session owns its surface.
sessions[].handoff object or null { path, writtenAt, location } when handoff-<sessionId>.md exists: path absolute, writtenAt the file’s mtime, location repo (repo root) or scratchpad (session scratchpad). Newer file wins; null when absent.
events[] array The last 20 events of the daemon’s lifetime, newest last: notifications, injections, and handoff sightings. Kept in memory only; a restart starts empty.
events[].at timestamp Poll in which the action ran.
events[].sessionId, shortId, dirName string The session it concerned.
events[].kind notify, inject, handoff Notification sent, message typed into the terminal, or a handoff file appeared for the session.
events[].level warn, critical, info Notification level; injections are critical for stop and handoff, warn for cache handoff; handoff events are always info.
events[].text string <title>: <body> for notifications; <kind> message: <text> for injections; <dirName>: handoff written at <path> for handoff events.
events[].dryRun boolean True when the action was only simulated. Always false for handoff events.

Pid, transcript path, and executable path are deliberately absent from the file.

The app is a status item with an eye icon and a percentage. It reads the status file every 3 seconds, and again whenever you open the menu, and renders what the daemon wrote. The daemon rewrites that file after every poll (every 30 seconds by default, set with --interval), and the app treats it as stale once it is older than 2.5 times the interval. It never reads transcripts or computes thresholds itself.

Run it by hand from the repo:

apps/menubar/.build/release/agent-watchdog-menubar

or, after link --menubar-bin ..., as agent-watchdog-menubar. It has no dock icon; its only window is the settings window (see below). Diagnostics go to stderr with the prefix agent-watchdog-menubar:.

What the icon shows. The colour and badge follow the level of the session with the highest percentage.

Daemon state Icon Title Colour
running, worst session ok eye worst percentage, 23% green
running, worst session warn eye with warning badge 41% orange
running, worst session critical eye with warning badge 81% red
running, no sessions eye -- green
stale (file older than 2.5 polls) crossed-out eye last known percentage or -- grey
stopped, missing, or unreadable crossed-out eye -- grey

Hovering the icon shows the daemon-state line. In icon-only mode (see below) the title column is dropped, the icon and colour stay, and the tooltip gains the percentage: Daemon running (last poll 12s ago) · worst session 81%.

The menu, rebuilt on every refresh. Two disabled notice lines can come first, only while the daemon is running and reports them: Config file ignored: <message> when the daemon runs on flags and defaults because its config file failed to load (hover for the message and the path), then Restart daemon to apply settings when the config file changed on disk after the daemon read it. Both repeat what the daemon published in the status file (configError and configFile.changedSinceLoad); the app never judges the file itself. While the status item is hidden (see “Hidden-item warning” below) a disabled Icon hidden since 19:14 (menu bar full or behind the notch) line follows. Then, top to bottom:

  1. Daemon state, one of:
    • Daemon running (last poll 12s ago)
    • Daemon not responding (last poll 4m ago) when the file is stale
    • Daemon stopped at 19:20 when the daemon shut down cleanly
    • Daemon not running, with the hint Start it with: agent-watchdog watch (or agent-watchdog install), when there is no file
    • Status file unreadable, with the decoding error, when the file is not valid status JSON
  2. Config summary: warn 35% · critical 75% · messaging off, with · dry-run appended when the daemon runs in dry-run mode.
  3. Sessions, worst first, one line each: agent-watchdog · fable-5-1 · 23% · running · cache expires in 41m. That is the directory name, the model without its claude- prefix, the percentage, the lifecycle, and when the prompt cache is expected to lapse (cache expired 12m ago once it has). The cache value is computed from the session’s last activity plus the cache TTL; it is not observed from the API. The segment is omitted when the daemon reported no expiry. Sessions at or above a threshold are prefixed [warn] or [critical]. Hover for tokens used of the window, the working directory, and the full session id. Clicking a session focuses it in cmux: the app runs cmux select-workspace and then cmux focus-panel for its surface. 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 nothing to show it reads No sessions (daemon running) or No session data.
  4. Recent events: up to five, newest first, as 19:14 [warn] cmux-remote: Context warning: claude 5cad2314 (/Users/me/..., truncated to 70 characters with (dry-run) appended for simulated actions. Hover for the full text. No events yet when there are none.
  5. Actions:
    • Refresh rereads the file now.
    • Settings... opens the settings window (below). Cmd-comma brings it to the front while it is open.
    • Icon only toggles icon-only mode, with a checkmark when it is on. When AGENT_WATCHDOG_MENUBAR_COMPACT is set the item is disabled and its title says so.
    • Stop daemon (running), Restart daemon (stale), or Start daemon (stopped, missing, unreadable). While the daemon is running and reports changedSinceLoad, a Restart daemon to apply settings item sits above Stop daemon. Start and both restarts run launchctl kickstart -k gui/<uid>/com.wholeclove.agent-watchdog and fall back to launchctl bootstrap of the plist; Stop runs launchctl bootout. When the plist is not installed, a disabled Daemon not installed (run: agent-watchdog install) line appears instead.
    • Open logs opens ~/Library/Logs/agent-watchdog/ in Finder. Disabled until the directory exists.
    • Open status file opens the status file in its default app. Disabled until the file exists.
    • Quit, with the Command-Q shortcut.

The app resolves cmux from /opt/homebrew/bin, then /usr/local/bin, then PATH. Every external command is launched with an argument array, never a shell string.

Settings window

Settings... opens one window with every daemon setting, grouped the way the daemon groups them: General, Polling, Sessions, Thresholds, Messaging, and Advanced. Nothing in it is hand-built from the option list. The window runs agent-watchdog config schema, reads the groups from that document, and renders one row per setting from its control kind: a switch for toggles, a numeric field with a stepper for percentages and numbers (bounded by the schema’s range), a text field for durations and paths (path rows add a Choose... button), one checkbox per allowed action for on-critical and on-cache-expiry, an editable list for session, cwd, and model-window, and a multi-line editor for the three message templates with the [agent-watchdog] prefix shown as a fixed label above the text. Each row shows the help text from the schema.

What each row tells you.

  • A source badge: default (the key is absent from the file and the built-in default applies), from file (the file sets it), or set by a flag (see below). The default value is shown as the field’s placeholder.
  • Daemon is running with: <value>, the live value from the status file’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 value the daemon actually uses can be compared.
  • An edited mark once you change it, and a Reset to default button that removes the key from the file rather than writing the default value, so a later change to the built-in default is picked up. Clearing a text field or emptying a list also removes the key.
  • Its validation error inline, in red. The window checks type, range, whole numbers, the duration syntax, the allowed actions, and the message prefix as you type; the cross-field rule (critical above warn) is the daemon’s and shows up on save.

Saving. Save (Cmd-S; Return never saves, so a newline in a message editor stays a newline) is enabled once something is edited and every local check passes. It writes the form to a temporary file 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 it, every field the error names is highlighted with the daemon’s message, messages that name no field appear above the form, 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 uses) or Later. A Restart daemon button also stays in the footer while the daemon is installed. Cancel (or Cmd-W) closes the window without writing.

Banners. Two come from the status file, which the app rereads every 3 seconds, so a restart clears them within a poll:

  • A red banner repeats the daemon’s configError while it runs on flags and defaults because the file failed to load.
  • 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. You see it after saving, and after any hand edit, until the restart. The menu shows the matching Restart daemon to apply settings line.

Locked rows. A row whose configSources entry is cli is disabled with the note Set by a command-line flag in the launch agent; reinstall the daemon to manage it here. That happens when a plist from an older install still bakes the flag, and a flag always wins over the file. To unlock it, run agent-watchdog install again: with no config file yet, the baked settings are moved into the file; with a file already present, they are dropped with a notice and the file’s values (or the defaults) take over. The row unlocks once the restarted daemon publishes the new sources. See “Install as a background service”.

Which file, which CLI. The window edits 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; AGENT_WATCHDOG_CONFIG_FILE in the app’s environment overrides both. The footer shows the path in use, or No config file yet; defaults shown before the first save, which creates the file and its directory. To run the CLI it takes, in order, the executable named by AGENT_WATCHDOG_CLI in its environment, the node and script paths from the launch agent plist (~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist), then agent-watchdog from /opt/homebrew/bin, /usr/local/bin, or PATH. When none works (no plist and no linked binary, or a daemon built before config schema existed) the window shows the command that failed and its stderr with a Try again button instead of a form; agent-watchdog link or install fixes it.

Pointing it at another file. Set AGENT_WATCHDOG_STATUS_FILE to read a status file elsewhere, for example one you wrote by hand or one a hand-run daemon writes with --status-file:

AGENT_WATCHDOG_STATUS_FILE=/tmp/wd-status.json apps/menubar/.build/release/agent-watchdog-menubar

~ in the value is expanded.

Quitting and relaunching. Quit from the menu. When the app was installed with install --menubar-bin, launchd does not restart it (KeepAlive is off), so it stays quit until your next login. Relaunch it with:

launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog-menubar

or just run the binary again. Relaunch after a rebuild the same way.

Permissions. The app needs no macOS permissions. It draws a status item, reads and writes files under your home directory, 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 your user. It does not use the notification framework itself, Accessibility, or screen recording, and the window-server query for its own status item needs no permission.

Troubleshooting: the icon is missing on a notched MacBook. macOS lays status items out from right to left, so the newest item lands closest to the notch, and when the bar is full it is parked under the notch with no warning of its own. The app is running; there is simply no room. Third-party items cannot be dragged off the bar, only reordered with Command-drag, and reordering can push a different item under the notch instead. Icon-only mode needs about 37 pt of free bar beside the notch; the full item needs about 76 pt. On the machine this was diagnosed on the bar had left a 29 pt gap, so even icon only did not fit and only freeing a slot helped: quit another menu bar app, or use a menu bar manager. The app notices when it is hidden and warns once (see “Hidden-item warning”). Confirm it is alive with launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog-menubar or pgrep -fl agent-watchdog-menubar, and look for the status item is hidden line in ~/Library/Logs/agent-watchdog/menubar.stderr.log.

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 settings decide it, in this order:

  1. The environment variable AGENT_WATCHDOG_MENUBAR_COMPACT. 1 forces icon only, 0 forces the full item, and either value disables the Icon only menu item (its title then says it is set by the environment). Use this in the launchd plist when the app is started by install --menubar-bin.

  2. The compact boolean in the app’s UserDefaults, which the Icon only menu item toggles. The app has no bundle, so the domain is named after the executable and the preference 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 app re-resolves the setting on every refresh, so a change applies within 3 seconds. If it does not, restart the app:

launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog-menubar

In icon-only mode the percentage moves into the tooltip and stays in every session row of the menu.

Hidden-item warning

After every refresh the app asks the window server whether its status item is on screen. Once per transition from visible (or never seen, as at launch) to hidden it:

  • writes one line to stderr (menubar.stderr.log under launchd), 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 and otherwise through Notification Center via osascript, with the icon-only and free-a-slot fixes in the body;
  • 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 the 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 a reading the window server has not laid out yet (zero width, as on the first refresh after launch) decides nothing.

Troubleshooting

The menu bar app says the daemon is not running, or the icon is grey. The status file is missing or older than 2.5 poll intervals. Check whether the agent is loaded and whether it is crash-looping:

launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog | head -30
tail -50 ~/Library/Logs/agent-watchdog/stderr.log

A poll failed line explains a stale file. A status file write failed line means the status path is not writable. If launchctl print shows a non-zero last exit status and the log is empty, the node path baked into the plist may be gone: install records the exact node binary that ran it, which under nvm is version-specific, so removing that Node version after an upgrade breaks the agent. Run install again from the new Node. If the app shows Daemon stopped at ... while the agent is loaded, a long-running hand watch (without --once) was stopped after writing the default status file; the installed daemon overwrites it on its next poll. Give hand runs --no-status-file or their own --status-file. A watch --once run never causes this.

install ends with ... is not loaded and exit code 1. install verifies the job with launchctl print after loading it, so this means launchd refused or dropped the job, usually because a bootout of the previous instance was still in flight. Ask launchd for its own account of the service, then re-run install once it has settled:

launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog

When print reports the service as missing, running the bootstrap by hand shows launchd’s own error:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist

An install that printed loaded via ... and exited 0 did leave a job behind.

The sidebar pill is wrong or missing. The daemon re-asserts its ctx NN% pill every poll, so a manual cmux clear-status watchdog is undone within one interval. If the pill flickers between two values, a second long-running watch is competing with the installed daemon; give it --no-sidebar.

No sessions found. status prints No active sessions found. when the store has no live entries and the transcript scan finds nothing modified within --stale-after. Check:

  • ~/.cmuxterm/claude-hook-sessions.json exists and lists your session. It is written by cmux’s Claude Code hooks, so sessions started outside cmux, or before the hooks were installed, appear only through the transcript scan (alert only, no surface, lifecycle unknown).
  • You did not pass --no-scan-transcripts, a --session prefix, or a --cwd that excludes everything.
  • The session has had activity within --stale-after (default 6h). Raise it to see older sessions.
  • Run agent-watchdog status --verbose to see which transcripts were skipped and why. A store entry whose transcript file is missing is logged at debug level once per session, not on every poll.

cmux not found. The daemon logs cmux binary not found on PATH; notifications fall back to osascript, messaging is unavailable (use --cmux-path). Set cmux-path in the config file (or the Advanced group of the settings window) and restart the daemon, or pass --cmux-path /path/to/cmux to a hand run. Under launchd, the plist’s PATH is fixed at install time to the cmux directory found then, so if cmux moved and you would rather not pin the path, run install again.

Notifications do not appear. cmux notifications need the cmux binary and a live surface; when cmux notify fails, the daemon logs cmux notify failed, falling back to osascript and posts a macOS notification instead. Those typically appear under the Script Editor identity, so check System Settings, Notifications, for Script Editor and for cmux, and make sure Focus is not silencing them. An osascript notification failed line means both paths failed. Confirm a rule actually fired: with --verbose, each session logs actions=N per poll, and the hysteresis rule means a threshold fires only once until it re-arms.

Messages are never typed. Look for skipped <kind> message: <reason> in stderr.log; those lines are logged at warn level, so they appear without --verbose. The reason names the safety check that refused. Add --verbose to also see deferred until the next genuine user turn (wake-loop guard) lines in stdout.log. Confirm the daemon is actually running with messaging on: the menu’s config summary says messaging on, and config.settings in the status file shows "message-agents": true and an on-critical or on-cache-expiry list that includes stop or handoff. If the file says so but the daemon does not, it has not been restarted since the save (see “Saved but nothing changed” below) or the file was ignored (see “The daemon ignores my config file”).

The daemon ignores my config file. Three places tell you why:

  1. agent-watchdog config validate prints the error the daemon hit, with exit code 2 (ok means the file is fine and the problem is elsewhere).
  2. The first line of the menu bar app’s menu reads Config file ignored: <message> while the daemon runs on flags and defaults; the settings window shows the same message in a red banner.
  3. configError in the status file carries the message, the path, and when the load failed. configSources then shows every setting as cli or default, and stderr.log has a config file ignored, running on flags and defaults line from the daemon’s start. A config file ignored notification was sent once at that start.

If config validate says ok, check that the daemon reads the file you are editing: configFile.path in the status file names it (a daemon installed with --config <path> reads that path, and one running under --no-config reads none, with configFile null). Fix the file, run config validate again, and restart the daemon; the error clears with the first status write. To get a hand run going while the file is broken, pass --no-config.

Settings window says a value is set by a flag. The row is disabled with Set by a command-line flag in the launch agent; reinstall the daemon to manage it here. A plist from an older install still bakes that setting after watch (install --print, or the plist under ~/Library/LaunchAgents, shows it), and a flag always wins over the file. Run agent-watchdog install again: it moves the baked settings into the config file when there is none yet, or drops them with a notice when the file already exists, then reloads the daemon. Reopen the window after the restart.

Saved but nothing changed. Settings are read once, at daemon start. After a save (or a hand edit) the daemon keeps its old values until it is restarted: the menu shows Restart daemon to apply settings, the settings window shows an orange banner with a Restart daemon button, and configFile.changedSinceLoad is true in the status file. Restart from either of those, or by hand:

launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog

Then compare Daemon is running with in the window, or the menu’s config summary, with what you saved. If it still differs, the setting is locked by a flag (previous entry) or the file was rejected on load (entry above).

Percentages look wrong. The window is an estimate. A session above 100% means the real window is larger than the built-in table assumes. Add --model-window <model>=<tokens> for the model shown by status.

Which logs to check.

File Contains
~/Library/Logs/agent-watchdog/stdout.log info and debug lines: startup config, per-session debug lines, re-arm notices, dry-run actions.
~/Library/Logs/agent-watchdog/stderr.log warn and error lines: every notification, every sent or skipped message, poll and write failures.
~/Library/Logs/agent-watchdog/menubar.stderr.log Menu bar app diagnostics: the status path it watches, every launchctl or cmux command it ran, and the status item is hidden and visible again lines.

The log files are not rotated; truncate them when they grow.

Resetting alert state. Alert state lives in ~/.local/state/agent-watchdog/state.json (--state-file). Because thresholds fire once per crossing, a session that already alerted will not alert again after a restart. To re-alert, stop the daemon, delete the file, and start it again:

launchctl bootout gui/$(id -u)/com.wholeclove.agent-watchdog
rm ~/.local/state/agent-watchdog/state.json
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist

For a one-off experiment, point a hand run at a fresh file instead: --state-file /tmp/wd-fresh.json.