agent-watchdog
A macOS command-line daemon that watches coding-agent sessions running inside cmux, estimates how much of each session’s context window is in use and when its prompt cache will expire, alerts you, and can optionally type a short message into the agent’s terminal asking it to stop or write a handoff.
Claude Code is the only provider today. The provider interface is small so Codex and others can be added later (see “Extending with a new provider”).
What it does
Every poll (default every 30 seconds) the watchdog:
- Discovers sessions (cmux session store first, then a transcript scan).
- Reads the tail of each session’s transcript to get the latest token usage, model, last activity time, prompt-cache TTL, and the most recent user turns.
- Evaluates the rules: context warning, context critical, cache expiring while idle.
- Notifies through cmux (falling back to a macOS notification via
osascript). - If messaging is enabled and every safety check passes, types a single-line message into the agent’s cmux surface and presses Enter.
- Persists per-session alert state so restarts do not re-alert.
- Keeps a
watchdogstatus pill in the cmux sidebar per workspace showing the worst session’s context percentage, re-asserted every poll (see “Sidebar status”; disable with--no-sidebar). - Writes a JSON status file for the menu bar app (see “Status file”; disable with
--no-status-file).
How sessions are found
| Source | Path | Can alert | Can message | Lifecycle known |
|---|---|---|---|---|
| cmux session store | ~/.cmuxterm/claude-hook-sessions.json |
yes | yes (with --message-agents) |
yes (running, idle, needsInput, unknown) |
| Transcript scan | ~/.claude/projects/*/*.jsonl |
yes | no (no surface) | no (unknown) |
The store is authoritative: it carries the session’s pid, surfaceId,
workspaceId, lifecycle, and transcript path. A session is considered “current for
its surface” only when the store’s activeSessionsBySurface entry for that surface
names it; older sessions on the same surface are listed but never messaged.
The transcript scan (opt out with --no-scan-transcripts) catches sessions that
run outside cmux or are missing from the store. It skips files named
agent-*.jsonl (subagent transcripts) and files not modified within
--stale-after. Scanned sessions have lifecycle unknown, so the idle test for
cache-expiry alerts relies on --idle-after alone, and they can only be notified.
Sessions with no activity in the last --stale-after (default 6h) are ignored from
both sources.
How context and cache expiry are computed
Transcripts are JSON lines. The watchdog reads the tail of the file in growing
windows (256 KB, 1 MB, 4 MB, then the whole file) until it finds the last
non-sidechain assistant entry that carries usage, and caches the result per
file by size and mtime so unchanged transcripts are not re-read every poll.
-
Context tokens =
input_tokens + cache_creation_input_tokens + cache_read_input_tokensof that entry. Missing numbers count as zero. Sidechain (subagent) entries and synthetic entries without usage are skipped. -
Last activity = that entry’s timestamp.
-
Cache TTL:
cache_creation.ephemeral_1h_input_tokens > 0means one hour; otherwiseephemeral_5m_input_tokens > 0means five minutes; otherwise the default of one hour.--cache-ttloverrides detection. -
Cache expiry = last activity + TTL.
-
Context window is an estimate. There is no ground truth in the transcript. Resolution order: exact
--model-windowmatch, longest-prefix--model-windowmatch, model ids containing[1m]or ending in-1m(1,000,000), the built-in table below (longest prefix), then--context-window(default 200000). Claude Code may run a model at a smaller window than the table depending on the plan; if the percentages look wrong, set--model-window <model>=<tokens>.Model id prefix Window claude-fable-,claude-mythos-1,000,000 claude-opus-5,claude-opus-4-8,claude-opus-4-7,claude-opus-4-61,000,000 claude-sonnet-5,claude-sonnet-4-61,000,000 claude-haiku-200,000 other claude-sonnet-,claude-opus-(4.5 and earlier)200,000 anything else --context-window -
Auto-compact: Claude Code compacts on its own somewhere above roughly 90% of its window; the exact point depends on the version and the real window size, which the watchdog only estimates. Keep
--critical-contextwell below it (default 75) so the watchdog reacts before compaction discards working context.
Commands
| Command | What it does |
|---|---|
watch (default) |
Poll loop. Handles SIGINT/SIGTERM, clears sidebar status on exit (--once and --dry-run never touch the sidebar). |
status |
One-shot table: provider, short session id, directory, model, tokens/window, percentage, lifecycle, last activity age, cache expiry, surface, handoff location. Exit 0. With --json, one status document in the status-file schema instead (see “Status file”). |
install |
Write settings flags into the config file, write ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist running watch with only the CLI-only flags, then load it with launchctl (see “Install as a launchd agent”). With --menubar-bin, also install the menu bar app. |
uninstall |
Unload and remove the launchd agent (and the menu bar agent when present). The config file is kept. |
link |
Symlink the built binary into ~/.local/bin/agent-watchdog (or --dir). With --menubar-bin, also link agent-watchdog-menubar. |
config schema |
Print the JSON Schema for the config file and the settings window (see “Settings schema”). config with no action or an unknown one exits 2 with a usage line. |
config validate [<path>] |
Check a config file (default: the --config path or ~/.config/agent-watchdog/config.json) and exit 0 with ok, or 2 with one line per error; --json prints { "ok": true } or { "ok": false, "errors": [{ "options", "message" }] } instead (see “Config file”). A missing file is valid. |
--help lists every flag with its default; --version prints the version.
Flags
Durations accept ms, s, m, h, and d units, combined (2h30m) or decimal (1.5h), or a plain number of seconds. Percentages are 0 to 100. Every value is validated with a clear error and exit code 2.
| Flag | Default | Applies to | Meaning |
|---|---|---|---|
--interval <duration> |
30s |
watch | Poll interval. |
--once |
off | watch | Run one poll and exit. |
--dry-run |
off | watch | Evaluate and log actions; never notify or send. |
--json |
off | watch, status, config validate | watch: one JSON line per poll on stdout (raw session snapshots plus actions, not the status-file schema); logs go to stderr. status: one status document in the status-file schema. config validate: the result as one JSON object. |
--sidebar / --no-sidebar |
on | watch | Sidebar status pill per workspace, re-asserted every poll. Ignored under --once and --dry-run. |
--status-file <path> |
~/.local/state/agent-watchdog/status.json |
watch | Status file written after every poll. Under --once only written when given explicitly. |
--no-status-file |
off | watch | Do not write the status file. |
--tolerate-config-error |
off | watch | Keep running on flags and built-in defaults when the config file is invalid, notify once, and publish the error in the status file instead of exiting 2. install always bakes it into the daemon plist; a foreground watch exits 2 on a bad file unless you pass it (see “Config file”). |
--scan-transcripts / --no-scan-transcripts |
on | all | Transcript-scan fallback discovery. |
--stale-after <duration> |
6h |
all | Ignore sessions idle longer than this. |
--idle-after <duration> |
2m |
all | Inactivity after which a session counts as idle. |
--warn-context <pct> |
35 |
all | Warning threshold. |
--critical-context <pct> |
75 |
all | Critical threshold; must be above the warning threshold. |
--cache-min-context <pct> |
20 |
all | Cache-expiry alerts only at or above this usage. |
--cache-lead <duration> |
10m |
all | Alert when the cache expires within this lead. |
--cache-ttl <duration> |
detect, else 1h |
all | Force the cache TTL. |
--context-window <tokens> |
200000 |
all | Window for models not in the built-in table. |
--model-window <model>=<tokens> |
none | all | Per-model window override; repeatable. |
--session <id-prefix> |
none | all | Only sessions whose id starts with the prefix; repeatable (OR). |
--cwd <path> |
none | all | Only sessions whose working directory is the path or lives under it; repeatable (OR). Both flags together AND. |
--message-agents |
off | all | Master switch for typing into agent terminals. |
--on-critical <list> |
notify |
all | Comma list of notify, stop, handoff. |
--on-cache-expiry <list> |
notify |
all | Comma list of notify, handoff. |
--stop-message <text> |
built in | all | Custom stop text; placeholders {pct}, {minutes}, {model}, {sessionId}, {handoffFile}, {now}, {cwd}. |
--handoff-message <text> |
built in | all | Custom handoff text; same placeholders. |
--cache-handoff-message <text> |
built in | all | Custom cache-expiry handoff text; same placeholders. |
--state-file <path> |
~/.local/state/agent-watchdog/state.json |
all | Alert-state file. |
--cmux-path <path> |
search PATH, then /opt/homebrew/bin, /usr/local/bin, /usr/bin, /bin |
all | cmux binary. |
--verbose / -v |
off | all | Debug logging. |
--config <path> |
~/.config/agent-watchdog/config.json |
all | Config file to read settings from; a flag always wins over the file (see “Config file”). |
--no-config |
off | all | Do not read any config file. |
--print |
off | install | Print the plist instead of installing it. |
--dir <path> |
~/.local/bin |
link | Symlink directory. |
--menubar-bin <path> |
none | install, link | Menu bar app binary; must exist and be executable. |
Custom messages support these 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 handoff file per session means several agents running at once never
overwrite each other’s handoff. The daemon watches for that file at the repo
root and in the session’s scratchpad directory (see “Handoff files”). Messages
are flattened to a single line, and the literal marker [agent-watchdog] is
prepended when the text does not already start with it. The defaults are:
- 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.
When --on-critical lists both stop and handoff, only the handoff message is
sent; it already asks the agent to stop.
Settings schema
agent-watchdog config schema prints a JSON Schema (draft 2020-12) describing
the daemon’s settings: one property per option that can live in a config file,
typed by how it is edited (durations as strings, percentages as numbers with a
0 to 100 range, action lists as arrays of allowed values, repeatable flags as
arrays of strings), plus an x-groups list the settings window renders from.
The same document is checked in at schema/settings.schema.json so editors and
the menu bar app can read it without running the CLI.
Every entry in src/cli/options.ts must carry settings metadata, either
'cli-only' (never in a file or the window: help, version, once, json,
no-status-file, config, no-config, tolerate-config-error, print,
dir, menubar-bin) or a group, control kind, and
help text. options.spec.ts checks the metadata and fails when the checked-in
schema no longer matches the options, so after changing an option run:
yarn workspace @agent-watchdog/watchdog build
yarn workspace @agent-watchdog/watchdog schema
and commit the regenerated file. The settings window is described in
docs/plans/settings-ui.md.
Config file
Every file-eligible setting can also live in ~/.config/agent-watchdog/config.json
(--config <path> reads another file, --no-config reads none), one JSON object
whose keys are the option names exactly as they appear on the command line:
{
"$schema": "/path/to/agent-watchdog/apps/watchdog/schema/settings.schema.json",
"warn-context": 40,
"cache-lead": "15m",
"sidebar": false,
"on-critical": ["notify", "handoff"],
"cwd": ["~/projects/a", "~/projects/b"]
}
Values are typed by the option’s control: toggles are booleans ("sidebar": false
is --no-sidebar), durations are strings in the flag syntax, percentages and
numbers are numbers (a numeric string is accepted too), paths and messages are
strings, repeatable flags are arrays of strings, and the action lists are arrays
of the allowed values. $schema is allowed and ignored by the daemon; point it
at the checked-in schema/settings.schema.json so an editor validates hand edits. Anything else the CLI
would reject, the file is rejected for as well: an unknown key, a CLI-only flag
(json, once, no-status-file, config, …), a wrong type, a percentage
outside 0 to 100, an unknown action, or the critical threshold at or below the
warning one. config validate [<path>] runs exactly those checks; use it after
a hand edit.
Precedence is flag > file > built-in default. The file only fills in
settings no flag set. install never bakes a setting into the launch agent;
it writes settings flags into this file and moves settings an older install
baked into the plist here too, so the file is the one place the daemon’s
settings live (see “Install as a launchd agent”). Two rules read the flags
only, never the file: watch --once skips the status file unless
--status-file was given as a flag (a path in the file does not count), and
the file’s own path comes from --config or --no-config. The status document
reports where every setting came from in configSources.
A bad file. watch, status, install, uninstall, and link exit 2
with the error, the way a bad flag does, so a mistake stops a foreground run
(--no-config skips the file when you need to run one of them regardless). Under launchd the
daemon must stay alive (KeepAlive would otherwise restart it into a loop with
nothing watching sessions), so install always bakes --tolerate-config-error
into the daemon plist, whether or not you pass it. A foreground watch exits 2
on a bad file unless you pass the flag yourself. With that flag the daemon
starts on flags plus built-in defaults,
logs the failure at error level, sends one notification through cmux (osascript
fallback) saying the config file was ignored, and publishes configError
(message, path, at) in every status document until it is restarted with a
fixed file. config schema and config validate never read the file for their
own settings, so they keep working while it is broken.
Handoff files
Every poll, the Claude provider checks whether the session’s handoff file exists
at either documented location, by stat alone (it never scans the transcript):
<cwd>/handoff-<sessionId>.md, the repo root; or/private/tmp/claude-<uid>/<project-slug>/<sessionId>/scratchpad/handoff-<sessionId>.md, the session’s scratchpad, where<project-slug>is the dash-encoded working directory that also names the session’s~/.claude/projects/<slug>transcript directory.
When both exist, the newer mtime wins. The result is the session’s handoff
field in the status document and watch --json, the HANDOFF column of
status, and, when a session goes from no file to a file, one handoff event
in the status document plus an info log line. The check repeats every poll, so
a deleted file drops the field back to null. Nothing is notified and a missing
handoff is never alerted on. If the repo must stay clean, add handoff-*.md to
its .gitignore or have the agent write to the scratchpad.
The default handoff messages ask for a fixed structure so the next session (or
the user) can pick the work up without re-deriving anything. The header comes
from the {now}, {sessionId}, and {cwd} placeholders; the agent fills in
the branch and commit itself. Sections, in order:
# 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.
Rules
- Warning: notify once when usage reaches
--warn-context. - Critical: notify once when usage reaches
--critical-context, and, if--message-agentsis set and--on-criticalincludesstoporhandoff, send that message. - Cache expiry: when usage is at or above
--cache-min-context, the cache is still alive but expires within--cache-lead, and the session is idle (lifecycleidle, or no activity for--idle-after), notify once and, if enabled, send the cache-handoff message. A cache that has already expired never triggers this rule: a handoff is no cheaper than continuing at that point (statusshows how long ago it lapsed, such as12m ago). - Hysteresis: each threshold fires once per crossing and re-arms when usage drops 5 points below it (after compaction or a new context). The cache-expiry alert re-arms when the cache is refreshed (expiry moves outside the lead) or usage drops below the minimum minus 5.
- State: kept in the state file so restarts do not re-alert. Sessions not seen for 24 hours are dropped, so a session that sits idle longer than that re-alerts once when it resumes above a threshold.
Status file
After every poll (including --dry-run, which is a read-only report that skips
the sidebar pill but not this file) the daemon
writes ~/.local/state/agent-watchdog/status.json (--status-file, next to
state.json; --no-status-file disables it). The write is atomic: <path>.<pid>.tmp
is written and renamed into place, so a reader never sees a partial file. On a
clean shutdown (SIGINT, SIGTERM) it is written one last time with stoppedAt set.
watch --once never writes the default status file, not even the final stoppedAt
document, so a one-shot run next to the installed daemon cannot make the menu bar
app report the daemon as stopped. To capture a one-shot run’s document, pass
--status-file <path> explicitly; it is then written after the poll and once more
with stoppedAt set, exactly as for the long-running daemon. agent-watchdog status --json prints the same document to stdout
without touching any file (daemonPid, startedAt, stoppedAt are null,
events is empty, at is now).
The reader is meant to render the file as-is: level is already derived from the
daemon’s --warn-context and --critical-context, so a reader never re-implements
thresholds. 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; null from status --json. |
startedAt |
timestamp / null | When the daemon started. |
at |
timestamp | When this document was produced (end of the poll). |
stoppedAt |
timestamp / 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[], plus settings: every file-eligible option by its name (warn-context, …) with the value the daemon runs with, normalized (durations in milliseconds, lists and multi-value enums as arrays, paths expanded, null for an optional setting that is not set). |
configFile |
object or null | { path, loadedAt, changedSinceLoad }: the config file consulted, when it was read (the daemon’s start), and whether its mtime differs from the one read, or it appeared or disappeared since (checked before every write; a restart applies the change). Null under --no-config. |
configSources |
object | Per file-eligible option name (warn-context, …), cli, file, or default: where the running value came from. |
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/--cwd filtering, in discovery order (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 |
critical at or above criticalContextPct, warn at or above warnContextPct, else ok. |
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 at the repo root (location repo) or in the session scratchpad (scratchpad); path is absolute, writtenAt is the file’s mtime. Newer file wins; null when absent. |
events[] |
array | The last 20 events of the daemon’s lifetime, newest last: executed actions and handoff sightings. Empty from status --json. |
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; for injections critical for stop/handoff, warn for cache-handoff; always info for handoff events. |
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 (--dry-run). Always false for handoff events. |
Process internals (pid, transcript path, executable) are deliberately absent.
Staleness rule for readers: the daemon rewrites the file every intervalMs, so
treat now - at > 2.5 * intervalMs as “daemon not running” (crashed, killed, or
its polls are failing: a failed poll writes nothing rather than claiming there are
no sessions). A non-null stoppedAt means it stopped cleanly. The events buffer
lives in memory only; a restart starts it empty.
Sidebar status
The long-running daemon keeps one watchdog pill per cmux workspace (cmux set-status watchdog "ctx 42%" --workspace <id>) showing the worst context
percentage among that workspace’s sessions, coloured green, orange at
--warn-context, red at --critical-context. The pill is re-asserted on every
poll whether or not the value changed, so anything that clears it from outside
(cmux clear-status watchdog, a crashed run) comes back within one interval. A
workspace with no remaining sessions has its pill cleared, and a clean shutdown
(SIGINT, SIGTERM, launchctl bootout) clears every pill the daemon set.
The sidebar key is shared, so only one process should own it. watch --once and
watch --dry-run therefore never set or clear the pill, and any longer ad-hoc
watch run alongside the installed daemon should pass --no-sidebar so the
daemon’s pill is left alone. --no-sidebar is baked into the plist by install
like every other watch flag.
Safety rules before a message is injected
Every condition must hold, otherwise the watchdog only notifies, logs the reason, and retries on a later poll:
--message-agentsis set.- The session came from the cmux store and has a surface.
- The store entry has a positive-integer
pidandpidStartSeconds, and the process is alive (kill -0). - The pid’s identity matches the store, from one
ps -p <pid> -o lstart=,stat=,command=call: its start time equalspidStartSecondsto the second (defeats pid reuse); its executable equals the store’slaunchCommand.executablePath(or, when the store has none, the command containsclaude); a--session-idin its argv, if present, equals this session; and itsstatcontains+(it is in the foreground of its terminal, so a suspended agent with a shell in front is refused). - The session is the current session for its surface per
activeSessionsBySurface. agentLifecycleis neitherneedsInput(a permission prompt may be open and typed text could answer it) norunknown.
All of this is checked twice: on the polled snapshot, and again on a fresh read of the session store immediately before typing, because a permission prompt or picker can open while other sessions are being processed. Any change (surface, pid, lifecycle) or read failure aborts the send.
Messages are sent with cmux send --surface <uuid> -- <text> followed by
cmux send-key --surface <uuid> -- Enter, via execFile with an argument array.
Nothing is interpolated into a shell. If the text was typed but the Enter key press
failed, the message still counts as injected (the wake-loop guard engages and it
is never typed twice); the watchdog logs an error and sends a notification asking
you to press Enter in that terminal.
The wake-loop guard
Typing into an idle agent wakes it, which refreshes its prompt cache and its
activity time. Without a guard, the cache-expiry rule would wake the agent every
time the cache neared expiry, forever. So after the watchdog injects any message
into a session, it will not inject again until a genuine user turn appears in the
transcript after that injection. A genuine user turn is a user entry with typed
text that does not start with [agent-watchdog]; tool results do not count. Both
the persisted injection time and any marker turn found in the transcript engage the
guard. Notifications keep following the hysteresis rule regardless.
If a send is owed but blocked by the guard, it stays pending and goes out on the first poll after the next genuine user turn (while the condition still holds).
Examples
Alert only, with the defaults:
agent-watchdog watch
Alerts plus a stop message at 80%:
agent-watchdog watch --message-agents --critical-context 80 --on-critical notify,stop
Handoff on cache expiry for sessions above 20% context:
agent-watchdog watch --message-agents --on-cache-expiry notify,handoff --cache-min-context 20
See what would happen without doing anything:
agent-watchdog watch --once --dry-run --verbose
Machine-readable output per poll (one line of raw session snapshots plus their actions; this is not the status-file schema):
agent-watchdog watch --json --no-sidebar
The current sessions as one JSON document in the status-file schema, without running the daemon:
agent-watchdog status --json
Testing message injection safely
Any threshold low enough to trigger a message would hit every real session, so
restrict the watchdog to one throwaway session with --cwd (or --session):
-
Create a scratch directory and start a plain
claudesession in it from a cmux terminal, so cmux registers it in the session store. Send it one prompt so the transcript has usage. -
Run one poll against that directory only:
agent-watchdog watch --once --cwd <that dir> --message-agents --on-critical stop \ --warn-context 0 --critical-context 1 \ --state-file /tmp/wd-test.json --no-status-file--warn-context 0is required: the critical threshold must be above the warning threshold (default 35), so--critical-context 1alone exits 2.--no-status-fileis redundant with--once(a one-shot run already skips the default status file) but keeps the command correct if you later drop--once. -
Expect the
[agent-watchdog]stop message to appear in that terminal and be submitted. The log linesent stop message to surface <uuid>confirms it; anyskippedline names the safety check that refused.
--dry-run stops before the send, so it cannot prove this path; only a real run
against a filtered session does. Sessions outside the filter are not evaluated, not
shown by status, and get no sidebar status.
The recipe is safe to run next to the installed daemon as written: --once (and
--dry-run) never set or clear the sidebar pill, so the daemon’s pill survives;
--once also skips the default status file, so the menu bar app keeps reading the
daemon’s document; and --state-file keeps the test’s alert state out of the
daemon’s state file. If you drop --once to watch the scratch session over several
polls, add --no-sidebar, and keep --no-status-file (or use a scratch
--status-file), which is no longer redundant: a long-running test run would
otherwise fight the daemon over the same watchdog sidebar key and the same status
file, clearing the pill and writing stoppedAt on exit (the daemon repairs both on
its next poll, but the flicker is avoidable).
Install as a launchd agent
A packaged tarball (see apps/site/README.md) satisfies the same
../../schema/settings.schema.json and ../../package.json lookups relative
to dist/bin/agent-watchdog.js, so everything below works unmodified against
an unpacked download, not just a source checkout.
Build first, then install with the settings you want the daemon to run with:
yarn workspace @agent-watchdog/watchdog build
node apps/watchdog/dist/bin/agent-watchdog.js install --message-agents --on-critical notify,stop
What goes where. install splits its flags in two:
- Settings (every flag that can live in the config file: thresholds, polling,
session filters, messaging, paths,
--verbose,--dry-run) are written to~/.config/agent-watchdog/config.json, or to the file named by--config. Only the keys given are written; other keys already in the file are kept. The file is written whole under a temporary name and renamed into place, gets a$schemapointer to the checked-in schema when it is created, and is run through the same checks asconfig validatefirst: if the merged file would be invalid (say--critical-context 50over a file that setswarn-contextto 60),installprints the error, writes nothing, and exits 2. Change a setting later by editing the file (or through the settings window) and restarting the daemon; no reinstall needed. - CLI-only flags that apply to
watch(--json,--no-status-file,--config <path>,--no-config) are baked into the plist, plus--tolerate-config-error, whichinstallalways adds so the daemon survives a bad config file (see “Config file”).--configis baked so the daemon reads the same fileinstallwrote.--no-configtogether with a settings flag is an error (exit 2): with no file the setting would have nowhere to go, andinstallnever bakes one.
Older installs. Before the config file existed, install baked every
flag into the plist. When a plist is already installed, install reads it
(plutil -convert json) before replacing it and looks at the settings baked
after watch:
- With no config file yet, those settings are moved into the new file and
each one is printed (
moved --warn-context 60 from the launch agent into ...). A flag given to thisinstallwins over a baked value of the same setting (replaced --warn-context 60 ... with --warn-context 40). - With a config file already present, the baked settings are dropped with a
notice (
dropped --warn-context 60: ...) 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.
The plist. install writes
~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist
with absolute paths for node and the built script, a PATH made of the directory
of the resolved cmux binary, the directory of the node binary that ran install,
then /opt/homebrew/bin, /usr/local/bin, /usr/bin, and /bin, RunAtLoad and
KeepAlive enabled, and logs to
~/Library/Logs/agent-watchdog/stdout.log and stderr.log. It then boots out
any previous instance and waits (up to 5 seconds) for launchd to finish tearing
it down, loads the agent with launchctl bootstrap gui/<uid> (retrying a few
times while launchd is still busy, then falling back to launchctl load -w),
and verifies with launchctl print that launchd kept the job. If it did not,
install prints an error saying the agent is not loaded and exits 1 instead of
claiming success; re-run it once launchd has settled.
Preview. install --print writes nothing: it 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.
Uninstall. uninstall boots the agent out, waits for launchd to report it
gone, and removes the plist, so uninstall && install back to back is safe.
The config file is kept (it says so when one exists); delete it by hand to
reset the settings.
Rebuilding does not require reinstalling: the plist points at dist/, so
launchctl kickstart -k gui/$(id -u)/com.wholeclove.agent-watchdog restarts it
on the new build. The same restart command picks up a changed config file.
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 (exit code 2 otherwise).
install then also writes
~/Library/LaunchAgents/com.wholeclove.agent-watchdog-menubar.plist (RunAtLoad,
KeepAlive off so quitting the app sticks until next login, ProcessType
Interactive, the same PATH, logs to menubar.stdout.log and menubar.stderr.log
in the same log directory) and loads it. install --print prints both plists.
uninstall boots out and removes the menu bar agent too when its plist is present.
Running from a cmux terminal
yarn workspace @agent-watchdog/watchdog build
node apps/watchdog/dist/bin/agent-watchdog.js link
agent-watchdog status
link symlinks ~/.local/bin/agent-watchdog to the built script and marks it
executable; yarn build must have run first. Use --dir for another directory and
make sure it is on your PATH. With --menubar-bin <path> it also symlinks
agent-watchdog-menubar in the same directory to that binary. An existing entry is
replaced only when it is already a symlink; a real file is never touched.
Menu bar app
apps/menubar is a native macOS menu bar app that reads the status file and shows
each session’s level; it never re-derives thresholds. See apps/menubar/README.md
for building and running it, and “Status file” above for what it reads.
Troubleshooting
- cmux not on PATH: the watchdog searches
PATH, then/opt/homebrew/bin,/usr/local/bin,/usr/bin, and/bin. Pass--cmux-path /path/to/cmuxotherwise. Without cmux, notifications fall back toosascriptand messaging is unavailable. - Sessions missing from the store: the store is written by cmux’s Claude Code
hooks. Sessions started outside cmux, or before the hooks were installed, only
appear through the transcript scan (alert only, no surface). Check
~/.cmuxterm/claude-hook-sessions.jsonand runagent-watchdog status --verbose. - Wrong window size: percentages are estimates from the built-in model table
(or
--context-windowfor unknown ids). A session above 100% means the real window is larger; add--model-window <model>=<tokens>for the model shown instatus. - No alerts after a restart: state is persisted; use a fresh
--state-fileor delete the default one to re-alert. - Messages never sent: every skipped send is logged at warn level with the
safety check that refused, so it is in
stderr.logwithout--verbose. A send held back by the wake-loop guard logsdeferred until the next genuine user turnat debug level, which needs--verbose. Confirm--message-agentsis set. - launchd agent not running: check
~/Library/Logs/agent-watchdog/stderr.logandlaunchctl print gui/$(id -u)/com.wholeclove.agent-watchdog.installwaits for a previous instance to unload and verifies the job after loading it, so aninstallthat printedloaded via ...and exited 0 did leave a job behind; aninstallthat ended withis not loaded(exit 1) means launchd refused or dropped the job, usually because a bootout was still in flight. Re-runinstall; if it keeps failing,launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plistshows launchd’s own error. - Sidebar pill missing: the daemon re-asserts it every poll, so wait one
interval. If it stays gone, the daemon is not running (see above) or was
installed with
--no-sidebar(install --printshows the baked-in flags). - Menu bar app says the daemon is not running: the status file is older than
2.5 poll intervals. Check the daemon logs above; a
status file write failedline means the path is not writable.
Extending with a new provider
Implement SessionProvider from src/providers/provider.ts: a name and
listSessions(now) returning SessionSnapshot[]. A snapshot carries the provider
name, session id, working directory, model, context tokens and window, last activity,
cache TTL and expiry, lifecycle, discovery source, optional cmux surface/workspace/pid,
whether the session is current for its surface, and the latest user-turn and
watchdog-injection timestamps. Register the provider in src/cli/commands.ts.
The cmux store reader (src/providers/claude/store.ts) is provider-neutral and
already handles the shape of ~/.cmuxterm/codex-hook-sessions.json; a Codex
provider mainly needs its own transcript parser.
Development
yarn workspace @agent-watchdog/watchdog build
yarn workspace @agent-watchdog/watchdog typecheck
yarn workspace @agent-watchdog/watchdog lint:check
yarn workspace @agent-watchdog/watchdog test
The rules engine (src/rules.ts) is pure and fully unit-tested; transcript
parsing, store validation, the cmux client, the action executor, and launchd
generation are tested with in-memory fakes. Nothing shells out in tests.