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, andosascript; 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.jsonto 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 (
.nvmrcpins 26; rootpackage.jsonrequires>=26). With nvm installed, runnvm usein the repo. - Yarn 4.11 is vendored in the repo (
.yarnrc.ymlpointsyarnPathat.yarn/releases/yarn-4.11.0.cjs). Anyyarnlauncher on your PATH, including the shim corepack provides, delegates to it. If you have noyarncommand at all, substitutenode .yarn/releases/yarn-4.11.0.cjsforyarnin 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:
- 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
$schemapointer to the checked-inapps/watchdog/schema/settings.schema.jsonin a source checkout, or the shippedschema/settings.schema.jsonnext to the unpacked tree for a downloaded install. The merged file is checked the wayconfig validatechecks it, and if it would be invalid (say--critical-context 50over a file that setswarn-contextto 60),installprints the error, writes nothing, and exits with code 2. - Writes
~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist. ItsProgramArgumentsare the absolute path of thenodebinary that raninstall, the absolute path ofapps/watchdog/dist/bin/agent-watchdog.js,watch, then only the CLI-only flags that apply towatch(--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”).RunAtLoadandKeepAliveare on andProcessTypeisBackground. - Sets the agent’s
PATHto the directory of the resolvedcmuxbinary, the directory of thatnodebinary,/opt/homebrew/bin,/usr/local/bin,/usr/bin, and/bin. - Creates
~/Library/Logs/agent-watchdog/and points the agent’s stdout and stderr atstdout.logandstderr.logthere. - Boots out any previously loaded copy and waits until
launchctl printno 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 withlaunchctl bootstrap gui/<uid>, retrying up to five more times with 500 ms gaps while launchd is still busy with the old instance, falls back tolaunchctl load -wif every attempt fails, and finally verifies the job withlaunchctl print. If launchd did not keep the job,installprints anis not loadedmessage 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 thisinstallwins 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, ord. Units can be chained (2h30m) and decimals are accepted (1.5h). A bare number is seconds (90is 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-contextmust 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-sidebarand--no-scan-transcripts. In the file the same settings are plain booleans ("sidebar": false).--no-status-fileis its own flag, because--status-filetakes a path, and it is CLI-only. - Where.
flag or filemeans the setting can be given as a flag or as a key in the config file, and appears in the settings window;flag onlymeans 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. |
Flags for install and link
| 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:
--message-agents, the master switch ("message-agents": truein the config file, or the toggle in the settings window). Without it nothing is ever typed and--on-criticalor--on-cache-expiryvalues other thannotifyare ignored.- 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"]). Dropnotifyfrom the list to skip the notification and only send the message. When--on-criticallists bothstopandhandoff, only the handoff is sent; it already asks the agent to stop. - 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:
--message-agentsis set.- The session has a cmux surface (it came from the store).
- The store records a pid and its start time, and that pid is alive.
- One
pscall 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 mentionsclaude), any--session-idin its arguments names this session, and it is in the foreground of its terminal (a suspended agent with a shell in front is refused). - The session is the current session for its surface. Older sessions on the same surface are never messaged.
- The lifecycle is neither
needsInput(a permission prompt may be open and typed text could answer it) norunknown.
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.
-
In a cmux terminal, create a scratch directory and start a plain
claudesession in it, so cmux registers the session in its store. Send it one prompt so the transcript carries token usage. -
Find its id:
agent-watchdog status --cwd ~/scratch/wd-test -
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.jsonWith
--onceno status file is written and the sidebar pill is left alone, so only the scratch state file is needed.--cwd ~/scratch/wd-testworks in place of--sessionwhen nothing else runs under that directory. -
Expect the stop message to appear in that terminal and be submitted. The log line
sent stop message to surface <uuid>confirms it. Askippedline names the safety check that refused:needsInputif 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.
Menu bar app
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:
- Daemon state, one of:
Daemon running (last poll 12s ago)Daemon not responding (last poll 4m ago)when the file is staleDaemon stopped at 19:20when the daemon shut down cleanlyDaemon not running, with the hintStart it with: agent-watchdog watch (or agent-watchdog install), when there is no fileStatus file unreadable, with the decoding error, when the file is not valid status JSON
- Config summary:
warn 35% · critical 75% · messaging off, with· dry-runappended when the daemon runs in dry-run mode. - 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 itsclaude-prefix, the percentage, the lifecycle, and when the prompt cache is expected to lapse (cache expired 12m agoonce 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 runscmux select-workspaceand thencmux focus-panelfor its surface. 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 nothing to show it readsNo sessions(daemon running) orNo session data. - 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 yetwhen there are none. - Actions:
Refreshrereads the file now.Settings...opens the settings window (below). Cmd-comma brings it to the front while it is open.Icon onlytoggles icon-only mode, with a checkmark when it is on. WhenAGENT_WATCHDOG_MENUBAR_COMPACTis set the item is disabled and its title says so.Stop daemon(running),Restart daemon(stale), orStart daemon(stopped, missing, unreadable). While the daemon is running and reportschangedSinceLoad, aRestart daemon to apply settingsitem sits aboveStop daemon. Start and both restarts runlaunchctl kickstart -k gui/<uid>/com.wholeclove.agent-watchdogand fall back tolaunchctl bootstrapof the plist; Stop runslaunchctl bootout. When the plist is not installed, a disabledDaemon not installed (run: agent-watchdog install)line appears instead.Open logsopens~/Library/Logs/agent-watchdog/in Finder. Disabled until the directory exists.Open status fileopens 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), orset 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’sconfig.settings(durations as45s, lists joined with commas,not setfor an optional setting without a value), so the saved value and the value the daemon actually uses can be compared.- An
editedmark once you change it, and aReset to defaultbutton 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
configErrorwhile it runs on flags and defaults because the file failed to load. - An orange banner with a
Restart daemonbutton appears while the daemon reportschangedSinceLoad: 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 matchingRestart daemon to apply settingsline.
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:
-
The environment variable
AGENT_WATCHDOG_MENUBAR_COMPACT.1forces icon only,0forces the full item, and either value disables theIcon onlymenu item (its title then says it is set by the environment). Use this in the launchd plist when the app is started byinstall --menubar-bin. -
The
compactboolean in the app’s UserDefaults, which theIcon onlymenu 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.logunder 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, throughcmux notifywhen cmux is installed and otherwise through Notification Center viaosascript, 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.jsonexists 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, lifecycleunknown).- You did not pass
--no-scan-transcripts, a--sessionprefix, or a--cwdthat excludes everything. - The session has had activity within
--stale-after(default 6h). Raise it to see older sessions. - Run
agent-watchdog status --verboseto see which transcripts were skipped and why. A store entry whose transcript file is missing is logged atdebuglevel 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:
agent-watchdog config validateprints the error the daemon hit, with exit code 2 (okmeans the file is fine and the problem is elsewhere).- 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. configErrorin the status file carries the message, the path, and when the load failed.configSourcesthen shows every setting ascliordefault, andstderr.loghas aconfig file ignored, running on flags and defaultsline from the daemon’s start. Aconfig file ignorednotification 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.