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

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.
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.
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.TaskStop with the watch's first id, the one Claude saw, still stops it.Claude Code 2.1.289 or later, with function-hook mods available to your account.
/plugin marketplace add nnemirovsky/cc-monitor-rearm
/plugin install monitor-rearm@monitor-rearm
There is nothing to configure.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.ts 97 lines1import 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