agent-watchdog

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:

  1. Discovers sessions (cmux session store first, then a transcript scan).
  2. 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.
  3. Evaluates the rules: context warning, context critical, cache expiring while idle.
  4. Notifies through cmux (falling back to a macOS notification via osascript).
  5. If messaging is enabled and every safety check passes, types a single-line message into the agent’s cmux surface and presses Enter.
  6. Persists per-session alert state so restarts do not re-alert.
  7. Keeps a watchdog status 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).
  8. 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_tokens of 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 > 0 means one hour; otherwise ephemeral_5m_input_tokens > 0 means five minutes; otherwise the default of one hour. --cache-ttl overrides detection.

  • Cache expiry = last activity + TTL.

  • Context window is an estimate. There is no ground truth in the transcript. Resolution order: exact --model-window match, longest-prefix --model-window match, 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-6 1,000,000
    claude-sonnet-5, claude-sonnet-4-6 1,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-context well 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-agents is set and --on-critical includes stop or handoff, 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 (lifecycle idle, 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 (status shows how long ago it lapsed, such as 12m 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.

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:

  1. --message-agents is set.
  2. The session came from the cmux store and has a surface.
  3. The store entry has a positive-integer pid and pidStartSeconds, and the process is alive (kill -0).
  4. The pid’s identity matches the store, from one ps -p <pid> -o lstart=,stat=,command= call: its start time equals pidStartSeconds to the second (defeats pid reuse); its executable equals the store’s launchCommand.executablePath (or, when the store has none, the command contains claude); a --session-id in its argv, if present, equals this session; and its stat contains + (it is in the foreground of its terminal, so a suspended agent with a shell in front is refused).
  5. The session is the current session for its surface per activeSessionsBySurface.
  6. agentLifecycle is neither needsInput (a permission prompt may be open and typed text could answer it) nor unknown.

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):

  1. Create a scratch directory and start a plain claude session in it from a cmux terminal, so cmux registers it in the session store. Send it one prompt so the transcript has usage.

  2. 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 0 is required: the critical threshold must be above the warning threshold (default 35), so --critical-context 1 alone exits 2. --no-status-file is redundant with --once (a one-shot run already skips the default status file) but keeps the command correct if you later drop --once.

  3. Expect the [agent-watchdog] stop message to appear in that terminal and be submitted. The log line sent stop message to surface <uuid> confirms it; any skipped line 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 $schema pointer to the checked-in schema when it is created, and is run through the same checks as config validate first: if the merged file would be invalid (say --critical-context 50 over a file that sets warn-context to 60), install prints 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, which install always adds so the daemon survives a bad config file (see “Config file”). --config is baked so the daemon reads the same file install wrote. --no-config together with a settings flag is an error (exit 2): with no file the setting would have nowhere to go, and install never 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 this install wins 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.

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/cmux otherwise. Without cmux, notifications fall back to osascript and 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.json and run agent-watchdog status --verbose.
  • Wrong window size: percentages are estimates from the built-in model table (or --context-window for unknown ids). A session above 100% means the real window is larger; add --model-window <model>=<tokens> for the model shown in status.
  • No alerts after a restart: state is persisted; use a fresh --state-file or 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.log without --verbose. A send held back by the wake-loop guard logs deferred until the next genuine user turn at debug level, which needs --verbose. Confirm --message-agents is set.
  • launchd agent not running: check ~/Library/Logs/agent-watchdog/stderr.log and launchctl print gui/$(id -u)/com.wholeclove.agent-watchdog. install waits for a previous instance to unload and verifies the job after loading it, so an install that printed loaded via ... and exited 0 did leave a job behind; an install that ended with is not loaded (exit 1) means launchd refused or dropped the job, usually because a bootout was still in flight. Re-run install; if it keeps failing, launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wholeclove.agent-watchdog.plist shows 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 --print shows 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 failed line 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.