SLOPSHOPPER

monitor-rearm

Re-arms Claude Code's long Monitor watches when they expire, so a 30-minute cap no longer wakes Claude or spends a turn

newguardprompt
v0.1.0MITupdated 2026-10-09nnemirovsky/cc-monitor-rearm
A shopper browsing a rack in a slop shop
README

Monitor Rearm

Claude Code stops every Monitor watch after at most 30 minutes and wakes Claude with a notice telling it to re-arm the watch. Claude then spends a turn, rereading the whole conversation, only to call Monitor again with the same input. Over an afternoon of watching a pull request or a log that adds up to a lot of turns that do nothing.

This plugin re-arms the watch itself, the moment it expires, with the same input, and drops the notice. Claude is not woken and no tokens are spent. In the transcript each re-arm leaves one dim line:

⏺ ↻ re-armed PR checks (b5vptfscx)

Events from the re-armed watch reach Claude as before.

What it re-arms

Only watches Claude asked for at the maximum, timeout_ms of 1800000 or more. Claude Code's own instructions tell Claude to use the maximum for a long watch and re-arm it on every expiry, which is what this does. Shorter watches expire as usual.

A watch re-armed for a whole day expires as usual too, so Claude gets to look at a watch that has been running, maybe quietly, for that long.

Watches started by subagents are left alone.

Things to know

  • Re-arming runs the watch's command again. A command that prints the current state when it starts (tail -f without -n 0, a script that reports status on its first pass) repeats that every 30 minutes, as it would when Claude re-arms it.
  • The re-armed watch has a new task id, which its events carry. TaskStop with the watch's first id, the one Claude saw, still stops it.
  • Claude does not see the expiry notice, including its hint to widen a filter when a watch saw no events. A filter that never matches keeps running quietly until the day is up.
  • The re-arm goes through the same permission check as Claude's own call.
  • The plugin keeps its list of watches in memory. After a reload or an update of the plugin, watches started before it expire as usual.
  • It reads the expiry notice by its text. If a Claude Code update changes that text, expiries go to Claude as before.

Requirements

Claude Code 2.1.289 or later, with function-hook mods available to your account.

Install

/plugin marketplace add nnemirovsky/cc-monitor-rearm
/plugin install monitor-rearm@monitor-rearm

There is nothing to configure.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 1 files
hooks/register.ts 97 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Claude Code stops every Monitor after at most 30 minutes and wakes Claude
4// with an expiry notice, which it answers by calling Monitor again: a turn
5// and a full context read just to keep a watch going. A watch asked for at
6// that maximum is meant to run on, so this mod re-arms it with the same input
7// the moment it expires and drops the notice. Shorter watches expire as usual,
8// and so does a long one after a day, so Claude gets to look at a watch that
9// has stayed quiet for that long.
10
11const LONG_MS = 1800000
12const DAY_MS = 86400000
13const REARMED = 'monitor-rearm: re-armed '
14
15// One <task-notification> carrying a Monitor's expiry notice; group 1 its task id.
16const EXPIRY = /<task-notification>\s*<task-id>([^<]+)<\/task-id>(?:(?!<\/task-notification>)[\s\S])*?\[Monitor expired after [^\]]*\](?:(?!<\/task-notification>)[\s\S])*<\/task-notification>\s*/g
17
18type Watch = { input: Record<string, unknown>; first: string; since: number }
19
20// Long watches by their current task id, and the current id of each watch by
21// the id Claude knows it by (its first).
22const watches = new Map<string, Watch>()
23const current = new Map<string, string>()
24
25function taskId(res: { result?: unknown }): string | undefined {
26  const r = res.result as { taskId?: unknown; persistent?: boolean } | undefined
27  return typeof r?.taskId === 'string' && !r.persistent ? r.taskId : undefined
28}
29
30async function rearm($: EngineInterface, id: string): Promise<string | undefined> {
31  const watch = watches.get(id)
32  if (!watch) return undefined
33  watches.delete(id)
34  if ((await $.clock.now()) - watch.since >= DAY_MS) {
35    current.delete(watch.first)
36    return undefined
37  }
38  const res = await $.tool.call({ tool: 'Monitor', ...watch.input } as never)
39  const next = taskId(res)
40  if (!next) {
41    current.delete(watch.first)
42    return undefined
43  }
44  watches.set(next, watch)
45  current.set(watch.first, next)
46  return `${String(watch.input.description ?? 'Monitor')} (${watch.first})`
47}
48
49export const register: Register = on => {
50  // Remember each long watch the main conversation starts.
51  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
52    const res = await next(e)
53    const { tool, tool_use_id, consent, agentId, ...input } = e as unknown as Record<string, unknown>
54    const id = taskId(res)
55    if (id && !agentId && Number(input.timeout_ms) >= LONG_MS) {
56      watches.set(id, { input, first: id, since: await $.clock.now() })
57    }
58    return res
59  })
60
61  // Claude still knows a re-armed watch by its first task id.
62  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
63    const asked = (e as unknown as { task_id?: string }).task_id
64    const now = asked ? current.get(asked) : undefined
65    if (!now) return next(e)
66    watches.delete(now)
67    current.delete(asked!)
68    return next({ ...e, task_id: now } as typeof e)
69  })
70
71  // The expiry notice: re-arm and take it out, so no turn starts for it.
72  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
73    if (!e.text.includes('[Monitor expired after')) return next(e)
74    const names: string[] = []
75    let kept = e.text
76    for (const m of e.text.matchAll(EXPIRY)) {
77      const name = await rearm($, m[1]!)
78      if (!name) continue
79      names.push(name)
80      kept = kept.replace(m[0], '')
81    }
82    if (names.length === 0) return next(e)
83    if (kept.trim()) return next({ ...e, text: kept })
84    return { drop: REARMED + names.join(', ') }
85  })
86
87  // The engine notes the dropped notice in the transcript; say what happened instead.
88  on('session.append', { door: 'notice' }, async ($, e, next) => {
89    const content = e.message.content
90    const text = typeof content === 'string' ? content : content.map(b => ('text' in b ? b.text : '')).join('')
91    const at = text.indexOf(REARMED)
92    if (at < 0) return next(e)
93    const line = `↻ re-armed ${text.slice(at + REARMED.length)}`
94    return next({ ...e, message: { ...e.message, content: [{ type: 'text', text: line }] } } as typeof e)
95  })
96}
97