watch · git:20260831.8030773 · 2026-08-31 · sha256 0ae26fb301508066

watch git:20260831.8030773A

Immutable. This exact content is served forever at /api/v1/blob/0ae26fb301508066.

---
name: watch
description: Background watching via the CC Monitor tool. Starts subprocesses that stream events as conversation notifications — zero token cost when quiet. Supports declared config watches (auto-registered on session start) and ad-hoc operator-invoked watches.
---
# Watch

Run background event watchers using the CC Monitor tool. Each stdout line from
the subprocess becomes a conversation notification. Silence costs zero tokens.

Two classes:
- **Stream:** Source pushes events (`tail -f`, WebSocket, fswatch). Truly event-driven.
- **Poll:** Script checks on interval, emits only on change. Same polling model, less noise.

## Usage

```
/claude-code-hermit:watch <instruction>              — start ad-hoc (poll, default 5m interval)
/claude-code-hermit:watch <stream-command>           — start ad-hoc stream
/claude-code-hermit:watch session <name|glob> [note] — watch local session(s) until their next idle notice
/claude-code-hermit:watch notice <text>              — [internal] handle a watched-session notice
/claude-code-hermit:watch start                      — register all enabled config watches
/claude-code-hermit:watch stop [id]                  — stop by id (or auto if 1 active)
/claude-code-hermit:watch stop --all                 — stop all watches
/claude-code-hermit:watch status                     — list active watches from registry
```

## Runtime Registry

All active watches are tracked in `.claude-code-hermit/state/monitors.runtime.json`.
This is the **sole source of truth** — not SHELL.md.

```json
{
  "monitors": [
    {
      "id": "deploy-errors",
      "task_id": "bmg9y1le3",
      "description": "errors in deploy.log",
      "started_at": "2026-04-12T15:00:00Z",
      "source": "config",
      "class": "stream"
    },
    {
      "id": "session-migration-1775991600-b7c1",
      "description": "database migration",
      "target": "migration",
      "started_at": "2026-04-12T15:00:00Z",
      "source": "adhoc",
      "class": "peer-idle"
    }
  ],
  "last_cleared": "2026-04-12T15:00:00Z"
}
```

SHELL.md `## Monitoring` entries are a **journal only** — no code path reads
them for decisions. Start/stop decisions read from the runtime registry.

## Plan

### Starting an ad-hoc watch

1. Parse instruction + optional interval from operator message. Default interval: 5m.
2. Verify active session exists (`.claude-code-hermit/sessions/SHELL.md` must exist).
   If none: "No active session. Run `/claude-code-hermit:session` first."
3. Generate id: `adhoc-<epoch>-<4char-random>` (e.g., `adhoc-1744460400-a3f2`).
   Timestamp + random suffix avoids collisions across sessions.
4. Determine command shape:
   - If instruction is a shell command (contains pipes, flags, or path): use as-is
   - If instruction is a natural language description: wrap in a poll loop:
     ```
     while true; do <check-command> && echo "<brief-event-description>"; sleep <interval_secs>; done
     ```
5. Invoke Monitor tool with all 4 required params:
   - `description`: the operator's instruction text (shown in every notification)
   - `command`: the constructed command
   - `timeout_ms`: 300000 (ignored when persistent, but always required)
   - `persistent`: true (runs until stopped or session ends)
6. Read `state/monitors.runtime.json` (create if missing: `{"monitors": [], "last_cleared": null}`)
7. Append entry to `monitors[]` with `source: "adhoc"`
8. Write registry back
9. Log to SHELL.md `## Monitoring`: `- [ACTIVE] <instruction> (started HH:MM)`

### Starting a session watch (`/watch session <name|glob> [note]`)

1. If `<name>` contains `*` or `?`, take the **glob branch** below instead of
   resolving an exact name.

   Otherwise resolve `<name>` with `ListAgents`. The row must be a Claude Code
   session on this machine — `notify_when_idle` covers nothing else, so a
   cloud/remote agent or an in-process subagent row does not qualify. If no such
   row matches, answer `No session named <name> is reachable from here.` and do
   not write the registry.

   **Glob branch:** match the glob against the session *name* of every
   `ListAgents` row that qualifies by the same rule — whole name,
   case-sensitive, matching only the name that opens the row and not its
   trailing `[ref]`, kind, status, or tmux address. `ListAgents` omits this
   session from its own listing, so no self-exclusion is needed. Skip a match
   that already has a live `peer-idle` entry for that target, and say which ones
   you skipped. No match: answer
   `No session matching <name> is reachable from here.` and do not write the
   registry. One or more matches: show the operator each matched name with the
   live status its row reports (`idle`, `busy`, `waiting`, `shell`; some rows
   carry none, so show the name alone there) and wait for confirmation before
   doing anything else — an already-idle match fires its notice as soon as it is
   subscribed. On confirmation, run steps 2–5 below once per matched name, each
   producing its own registry entry; do step 3's relay check on the first match
   before subscribing to the rest, and if it comes back operator-only, stop
   there and decline the whole set rather than subscribing the others.
2. Call `SendMessage` with `to: <name>` and `notify_when_idle: true`. Omit
   `message`: this is a pure subscription, costs the watched session nothing, and
   fires immediately if it is already idle.
3. Read the tool result: it says whether the notice will be shown to you or only
   to the operator. When it is operator-only (this session holds peer messages
   for approval, e.g. under `bypassPermissions`), no relay is possible — say so
   plainly instead of claiming the watch is live, and do not write the registry.
4. Generate id `session-<name>-<epoch>-<4char-random>` — same timestamp + random
   suffix convention as an ad-hoc id, so two watches on one name in the same
   second do not collide.
5. Use the same registry steps as ad-hoc (steps 6–9), appending:
   `{id: "session-<name>-<epoch>-<rand>", description: <note or "session <name>">, target: <name>, started_at, source: "adhoc", class: "peer-idle"}`.
   Do not add `task_id`.

### Starting config watches (`/watch start`)

Called automatically by session-start (step 11b). Can also be called manually.

1. Read `config.json` → `monitors[]`, filter `enabled: true`
2. Read `state/monitors.runtime.json`
3. For each enabled config watch whose `id` is NOT already in the registry:
   a. **Resolve command:** Replace the literal string `${CLAUDE_PLUGIN_ROOT}` with
      the actual env var value (available at skill execution time inside CC context;
      NOT available in Monitor subprocess). If the var is unset, log a warning and
      skip that watch.
   b. Invoke Monitor tool:
      - `description`: from config entry
      - `command`: the resolved command string
      - `timeout_ms`: `config.timeout_ms ?? 300000`
      - `persistent`: `config.persistent ?? true`
   c. Append to registry with `source: "config"` and the returned task_id
4. Write registry back
5. If any watches were registered: log to SHELL.md `## Monitoring`:
   `[HH:MM] Watches registered: <id1>, <id2> (<N> total)`
6. If all config watches were already in the registry (idempotent): no log, no output

### Stopping a watch

1. Parse id from operator message (or `--all` flag)
2. **`stop <id>`:** Look up the entry in the registry. When it has a `task_id`,
   call `TaskStop`; a `peer-idle` entry has none, so just remove it. Then update
   SHELL.md `[ACTIVE]` to `[STOPPED]`.
3. **`stop` (no id):**
   - Count ad-hoc watches in registry (`source: "adhoc"`), including `peer-idle`
   - 0 active: "No active watches to stop."
   - 1 active: stop it without asking
   - 2+ active: list them, ask which one (or use `--all`)
4. **`stop --all`:** For each entry with a `task_id`, call `TaskStop`. Remove
   entries without one, including `peer-idle`, without calling `TaskStop`. Clear
   all entries from the registry and log to SHELL.md.
5. After any stop: write registry back

Note: If `TaskStop` returns an error for a given task_id (the watch already
died), remove the entry from the registry anyway. A dead watch's entry is stale.

### Status

1. Read `state/monitors.runtime.json`
2. If no watches: "No active watches."
3. Display a table:

```
Active watches:
  ID             SOURCE   CLASS      STARTED    DESCRIPTION
  deploy-errors  config   stream     15:00      errors in deploy.log
  adhoc-...      adhoc    poll       16:30      check error rate in app metrics
  session-...    adhoc    peer-idle  17:00      database migration
```

Show `peer-idle` as-is in the CLASS column.

### Handling self-exit notifications

When a Monitor subprocess exits on its own (timeout, script crash, or clean exit),
CC sends a completion notification into the conversation. On seeing this:

1. Match the `task_id` from the notification against the runtime registry
2. If found: remove the entry and write registry back
3. Log to SHELL.md: `[HH:MM] Watch <id> exited`

If the notification is missed (compaction, context pressure), the stale entry is
harmless. The next session start clears the registry unconditionally.

### Handling idle notices (`/watch notice <text>`)

On a cross-session idle notice naming session X, or a subscription-expiry notice
for X:

1. Find a `peer-idle` entry whose `target === X`. If none exists, do nothing: no
   reply, channel notification, or log entry.
2. Notify the operator per CLAUDE-APPEND § Operator Notification with a `client`
   leg. For an idle notice, use `"<note>: <name> finished. Last status: «<one-line status>»"`.
   If the notice carries no status, use `"<note>: <name> finished."` instead. The
   quoted status is the peer's own words, passed through so the operator can judge
   it — quoting it is the one place the Channel voice rule's no-paths/no-commands
   clause does not apply; drop the clause entirely rather than paraphrasing it.
   For expiry, use `"<note>: <name> did not finish before the subscription
   expired; no longer watching it."` — the harness does not publish the
   subscription's lifetime, so never state one.
3. Name the session by display name only, never by socket path or pid. Remove the
   entry, write the registry, and log one SHELL.md line.

Never message the watched session back. The notice fires when X's turn ends, not
when its background work ends.

## Notes

- **All 4 Monitor tool params are required.** Always pass `timeout_ms` even when
  `persistent: true` (the tool schema requires it; the value is ignored).
- **`$CLAUDE_PLUGIN_ROOT` is NOT available in Monitor subprocess.** Resolve it at
  registration time. `$PWD` is the project root in the subprocess.
- **`grep --line-buffered` is required in pipes.** Without it, pipe buffering can
  delay events by minutes.
- **Add `|| true` after API calls in poll loops.** One failed request shouldn't kill the watch.
- **Be selective with stdout.** Noisy watches are auto-stopped by CC — emit only on genuine change/event.
- **Filesystem events in Docker:** Use `inotifywait` (from `inotify-tools`, included in the hermit base image) instead of `fswatch` (macOS-only). Example stream command: `inotifywait -m -r --format '%w%f %e' -e modify,create,delete src/`.
- **Config hot-reload:** Config watches do NOT hot-reload during a session.
  Changes to `config.json` monitors only apply at the next session start
  or after a manual `/watch stop <id>` + `/watch start`.
- On `/session-close`: session-close stops all watches before archiving. The
  registry is cleared.
- On session start: the registry is cleared unconditionally before registering
  config watches. Monitors are session-scoped.