Pages your phone (ntfy or Telegram) when the coding agent finishes a long turn or needs you, and lets you reply from the phone.

Your phone buzzes when the coding agent needs you, and you can answer from the phone.
agent-pager is a Claude Code mod (a plugin of function hooks, Claude Code 2.1.287 or later). It sends a page through ntfy or your own Telegram bot when:
AskUserQuestion dialog that stays unanswered for a few seconds, a permission prompt you have not reacted to (the engine's permission_prompt notification, which fires after a short idle delay, so it stays quiet while you are typing), or an MCP input form.Messages you send back become prompts in the one session you picked with /pager listen. They are queued and run when that session is idle. A few words are commands instead:
| From the phone | Does | | :- | :- | | /status | Replies with the session state: working or idle, model, turn count, whether paging is paused | | /stop | Pauses automatic pages (same as /pager pause) | | /resume | Turns automatic pages back on | | /help | Lists these | | anything else | Queued as a prompt; when the turn that prompt started ends, its reply is sent back to you (other turns in between are not) |
All network traffic goes through the engine's $.http.fetch. The mod runs no processes and needs no server of its own.
agent-pager- followed by 16 random characters (openssl rand -hex 8). Anyone who knows the name can read the topic, so do not use a guessable one.backend to ntfy and ntfy_topic to the name (see Download and install). Run /pager test in Claude Code. The test page should arrive within a second or two.A self-hosted ntfy server works too: set ntfy_server to its URL and, for a protected topic, ntfy_token to an access token.
Replies over ntfy are off by default. ntfy has no notion of who sent a message, so ntfy_inbound only takes effect together with ntfy_token: use a protected topic (a self-hosted server, or a reserved topic with access control) where only that token can write. Without a token, /pager status says replies are off and why. With both set, run /pager listen in the session that should receive replies. Telegram is the simpler choice for replies.
/newbot and follow the questions. Copy the token it gives you (123456789:AA...).https://api.telegram.org/bot<TOKEN>/getUpdates in a browser and read message.chat.id from the answer.backend to telegram, telegram_token to the token and telegram_chat_id to the id. Run /pager test./pager listen in the session that should receive your replies. About ten seconds later it starts reading them.The bot must not have a webhook set, since the mod reads messages with getUpdates. Only a private chat with you is read: a message counts only when the chat is private and both the chat id and the sender id equal telegram_chat_id (in a private chat they are your user id). Groups are not supported, since anyone in a group could prompt your agent. Everything else is ignored without a reply.
From the marketplace, at the prompt of a terminal session:
/plugin marketplace add RadTech-Solutions/agent-pager
/plugin install agent-pager@agent-pager
or in one line:
/plugin install agent-pager --marketplace RadTech-Solutions/agent-pager
Answer y to add the marketplace and pick a scope. The install shows a screen that sets the options; the tokens are masked and stored in the system keychain. Non-secret options also appear as rows in /config.
From a clone, for one session:
git clone https://github.com/RadTech-Solutions/agent-pager
claude --plugin-dir ./agent-pager
Options for a --plugin-dir load are read from pluginConfigs in your user settings (~/.claude/settings.json), keyed by the plugin name:
{
"pluginConfigs": {
"agent-pager": {
"options": { "backend": "ntfy", "ntfy_topic": "agent-pager-3f9c0e1d2b7a4c55" }
}
}
}
For a one-off headless run, the same object can be passed with --settings.
Careful: values in pluginConfigs are stored as plain text. If you put telegram_token or ntfy_token there, the token sits unencrypted in settings.json. The marketplace install keeps them in the system keychain instead.
| Option | Default | Meaning | | :- | :- | :- | | backend | ntfy | ntfy, telegram or off | | ntfy_topic | empty | Topic to publish to | | ntfy_server | https://ntfy.sh | ntfy server | | ntfy_token | empty | Access token (sensitive) | | ntfy_inbound | false | Treat messages on the topic as prompts (needs ntfy_token) | | telegram_token | empty | Bot token (sensitive) | | telegram_chat_id | empty | The only chat that is read and written | | inbound | true | Read messages from the phone, in the session where you run /pager listen | | poll_seconds | 5 | Poll interval | | min_turn_seconds | 60 | Shortest turn that pages when it finishes; 0 pages every turn | | ask_delay_seconds | 10 | How long a question waits before it pages | | min_gap_seconds | 30 | Automatic pages closer together than this are dropped | | quiet_hours | empty | Local time range with no automatic pages, like 22-07 or 22:30-06:45 | | private_mode | false | Pages, including replies to your phone prompts, say only "Your agent needs you." | | notify_errors | true | Page when a turn ends on an API error or a refusal |
In Claude Code:
| Command | Does | | :- | :- | | /pager or /pager status | Where pages go (topic masked), whether paging is paused, thresholds, quiet hours (flagged when invalid), last page, last poll or send error | | /pager test | Sends a test page now, ignoring pause, quiet hours and the gap | | /pager pause | Stops automatic pages, in every session, until resumed | | /pager listen | Makes this session the one that reads messages from the phone | | /pager unlisten | Stops reading them; no session does until one runs /pager listen | | /pager resume | Turns them back on |
Pause and quiet hours apply to automatic pages. The minimum gap applies to routine pages (finished turns, errors); question and permission pages are never dropped for it. Answers to the phone (the reply to a prompt you sent, /status, /stop) always go out, since you just asked for them.
Pages go out from every session, but only one session reads messages from the phone: the one where you ran /pager listen. Until you run it somewhere, replies are not read at all. /pager status says which session is the receiver and when it last polled.
Why it works this way: the mod can only keep shared state in $.store, which has get, set and delete but no atomic compare-and-set, so two sessions cannot safely race each other for the role. Instead a claim is write, wait, verify. /pager listen writes the session's id under one key, waits at least two poll intervals (11 seconds with the default 5 second interval), reads the key again and starts reading only if it still names that session. When two sessions claim at once, the later write wins and the other stands down. The receiver checks that it still holds the role before each poll, after each fetch returns and before each message, so after a handoff the old receiver stops at its next check and drops anything it fetched after losing the role. Each message id is recorded as handled in the store before it is submitted, and the Telegram offset is acknowledged right after, so a message that both sessions saw during a handoff runs once.
What to expect from that:
/pager listen; nothing takes over on its own.On Telegram and on ntfy alike, messages sent before the receiving session started are skipped, so a prompt from yesterday does not run in today's session. Headless claude -p runs send pages but never read replies.
A failed poll is shown in /pager status and retried with exponential backoff, up to five minutes, or after the wait the server asks for (Telegram's retry_after). Error texts never include the bot token.
private_mode. /pager status shows only the first four characters of the topic.ntfy_inbound does nothing without ntfy_token, since an open topic would let anyone prompt your agent.telegram_chat_id is read./pager test and /status replies still name the project.telegram_token and ntfy_token are marked sensitive, so the install screen masks them and Claude Code stores them in the system keychain rather than settings.json. Setting them by hand under pluginConfigs stores them in plain text.claude plugin validate .
claude plugin test .
claude plugin test runs hooks/register.test.tsx against the engine with a mocked $.http.fetch, clock and store, and hooks/receiver.test.tsx, which drives the exported poller of two sessions at once over one shared store and a fake Telegram server: no receiver means no submits, only the listening session submits, simultaneous /pager listen settles on one receiver with one submit per update, and a handoff during an in-flight fetch or right after a submit does not duplicate. It covers the outbound page format, the Telegram private chat and sender filter, inbound messages reaching $.prompt.submit, the reply going back for the phone's own turn and not for a keyboard turn in between, /status and /stop from the phone, subagent turns, rate limits and backoff, listen, unlisten and takeover, quiet hours (valid and invalid), pause and resume, the minimum gap and its urgent exemption, private mode, the question delay, permission and error pages, and ntfy inbound with its stale message and token checks.
Type checking keeps the tsconfig.json outside the repository; the recipe is in the header of types/index.d.ts.
RadTech builds apps, web products and applied AI, and advises founders. We do AI advisory, building and consulting.
hooks/register.tsx 689 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Backend, Page, PagerConfig } from '../types'
5
6// agent-pager pages a phone through ntfy or Telegram when a turn ran long,
7// when the agent waits on a question or a permission prompt, and when a turn
8// dies on an error. Messages from the phone become queued prompts.
9//
10// Deliberate limit: nothing here approves or denies a tool call. Permission
11// prompts are announced, never answered remotely.
12
13const busy = atom({ plugin: 'agent-pager', key: 'busy' } as const, false)
14const startedAt = atom({ plugin: 'agent-pager', key: 'startedAt' } as const, 0)
15const interactive = atom({ plugin: 'agent-pager', key: 'interactive' } as const, false)
16// Prompts sent from the phone that have not started a turn yet, and the ids
17// of the turns they started: only those turns' replies go back to the phone.
18const pending = atom({ plugin: 'agent-pager', key: 'pending' } as const, [])
19const remoteTurns = atom({ plugin: 'agent-pager', key: 'remoteTurns' } as const, [])
20// True while this session is the confirmed receiver of phone messages.
21const listening = atom({ plugin: 'agent-pager', key: 'listening' } as const, false)
22
23const BODY_CHARS = 200
24const TELEGRAM_API = 'https://api.telegram.org'
25const OUT_TAG = 'pager-out'
26const MAX_BACKOFF_MS = 5 * 60 * 1000
27
28const HELP = [
29 'agent-pager commands:',
30 '/status session state',
31 '/stop pause automatic pages',
32 '/resume resume automatic pages',
33 'Anything else is queued as a prompt for the session.',
34].join('\n')
35
36// The poller's timer lives in this copy of the module; a hot reload drops
37// both the timer and this flag, and the next event starts it again.
38let pollerRunning = false
39
40const str = (v: unknown, d = '') => (typeof v === 'string' ? v.trim() : d)
41const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
42const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
43
44export function readConfig(o: PluginOptions): PagerConfig {
45 const backend = str(o.backend, 'ntfy')
46 return {
47 backend: (['ntfy', 'telegram', 'off'].includes(backend) ? backend : 'ntfy') as Backend,
48 ntfyTopic: str(o.ntfy_topic),
49 ntfyServer: (str(o.ntfy_server) || 'https://ntfy.sh').replace(/\/+$/, ''),
50 ntfyToken: str(o.ntfy_token),
51 ntfyInbound: bool(o.ntfy_inbound, false),
52 telegramToken: str(o.telegram_token),
53 telegramChatId: str(o.telegram_chat_id),
54 inbound: bool(o.inbound, true),
55 pollMs: Math.max(2, num(o.poll_seconds, 5)) * 1000,
56 minTurnMs: Math.max(0, num(o.min_turn_seconds, 60)) * 1000,
57 askDelayMs: Math.max(0, num(o.ask_delay_seconds, 10)) * 1000,
58 minGapMs: Math.max(0, num(o.min_gap_seconds, 30)) * 1000,
59 quietHours: str(o.quiet_hours),
60 privateMode: bool(o.private_mode, false),
61 notifyErrors: bool(o.notify_errors, true),
62 }
63}
64
65/** Why the backend cannot send, or null when it can. */
66export function missing(cfg: PagerConfig): string | null {
67 if (cfg.backend === 'off') return 'backend is off'
68 if (cfg.backend === 'ntfy' && !cfg.ntfyTopic) return 'ntfy_topic is not set'
69 if (cfg.backend === 'telegram' && !cfg.telegramToken) return 'telegram_token is not set'
70 if (cfg.backend === 'telegram' && !cfg.telegramChatId) return 'telegram_chat_id is not set'
71 return null
72}
73
74/** Why replies from the phone are off, or null when they are read. */
75export function inboundOff(cfg: PagerConfig): string | null {
76 if (!cfg.inbound) return 'inbound is off'
77 if (missing(cfg)) return missing(cfg)
78 if (cfg.backend === 'ntfy') {
79 if (!cfg.ntfyInbound) return 'ntfy_inbound is off'
80 if (!cfg.ntfyToken) return 'ntfy_inbound needs ntfy_token on a protected topic'
81 }
82 return null
83}
84
85export function clip(s: string, n: number): string {
86 const one = s.replace(/\s+/g, ' ').trim()
87 return one.length > n ? one.slice(0, n - 1) + '…' : one
88}
89
90/** Strips a Telegram bot token out of a text that may quote a URL. */
91export function redact(s: string): string {
92 return s.replace(/bot[^/\s]+/g, 'bot<token>')
93}
94
95export function mask(s: string): string {
96 return s.length <= 4 ? s : s.slice(0, 4) + '*'.repeat(Math.min(12, s.length - 4))
97}
98
99export function duration(ms: number): string {
100 const s = Math.round(ms / 1000)
101 if (s < 60) return `${s}s`
102 const m = Math.floor(s / 60)
103 if (m < 60) return `${m}m ${s % 60}s`
104 return `${Math.floor(m / 60)}h ${m % 60}m`
105}
106
107function minutesOf(t: string): number | null {
108 const m = /^(\d{1,2})(?::(\d{2}))?$/.exec(t.trim())
109 if (!m) return null
110 const h = Number(m[1])
111 const min = Number(m[2] ?? 0)
112 if (h > 24 || min > 59) return null
113 return (h % 24) * 60 + min
114}
115
116/** The range as minutes of the day, or null when it does not parse. */
117export function parseQuietHours(range: string): [number, number] | null {
118 const parts = range.split('-')
119 if (parts.length !== 2) return null
120 const from = minutesOf(parts[0] ?? '')
121 const to = minutesOf(parts[1] ?? '')
122 if (from === null || to === null || from === to) return null
123 return [from, to]
124}
125
126/** True when `now` (epoch ms, read in local time) falls inside `range` ("22-07"). */
127export function inQuietHours(range: string, now: number): boolean {
128 if (!range) return false
129 const r = parseQuietHours(range)
130 if (!r) return false
131 const [from, to] = r
132 const d = new Date(now)
133 const cur = d.getHours() * 60 + d.getMinutes()
134 return from < to ? cur >= from && cur < to : cur >= from || cur < to
135}
136
137function projectName(cwd: string): string {
138 return cwd.split('/').filter(Boolean).pop() ?? cwd
139}
140
141/** The title and text a page carries once private mode is applied. */
142export function render(page: Page, project: string, privateMode: boolean): { title: string; text: string } {
143 if (privateMode && page.kind !== 'test' && page.kind !== 'status') {
144 return { title: 'agent-pager', text: 'Your agent needs you.' }
145 }
146 return { title: `${project}: ${page.label}`, text: page.body }
147}
148
149async function sendRaw($: EngineInterface, cfg: PagerConfig, title: string, text: string, urgent: boolean): Promise<string | null> {
150 if (cfg.backend === 'telegram') {
151 const r = await $.http.fetch(`${TELEGRAM_API}/bot${cfg.telegramToken}/sendMessage`, {
152 method: 'POST',
153 headers: { 'content-type': 'application/json' },
154 body: JSON.stringify({ chat_id: cfg.telegramChatId, text: text ? `${title}\n${text}` : title, disable_web_page_preview: true }),
155 })
156 return r.ok ? null : `telegram answered ${r.status}`
157 }
158 const headers: Record<string, string> = { 'content-type': 'application/json' }
159 if (cfg.ntfyToken) headers.authorization = `Bearer ${cfg.ntfyToken}`
160 const r = await $.http.fetch(cfg.ntfyServer, {
161 method: 'POST',
162 headers,
163 body: JSON.stringify({
164 topic: cfg.ntfyTopic,
165 title,
166 message: text || title,
167 tags: [OUT_TAG],
168 priority: urgent ? 4 : 3,
169 }),
170 })
171 return r.ok ? null : `ntfy answered ${r.status}`
172}
173
174/**
175 * Sends one page unless something holds it back. Automatic pages respect
176 * pause and quiet hours, and routine ones the minimum gap too; a question or
177 * permission page is never dropped for the gap. Forced pages (tests, answers
178 * to the phone) go regardless. Resolves why it did not send, or null.
179 */
180export async function page($: EngineInterface, cfg: PagerConfig, p: Page): Promise<string | null> {
181 const why = missing(cfg)
182 if (why) return why
183 const now = await $.clock.now()
184 const urgent = p.kind === 'question' || p.kind === 'permission'
185 if (!p.force) {
186 if ((await $.store.get('paused')) === true) return 'paused'
187 if (inQuietHours(cfg.quietHours, now)) return 'quiet hours'
188 const last = num(await $.store.get('lastPageAt'), 0)
189 if (!urgent && cfg.minGapMs > 0 && now - last < cfg.minGapMs) return 'too soon after the last page'
190 }
191 const project = projectName(await $.session.cwd())
192 const { title, text } = render(p, project, cfg.privateMode)
193 let err: string | null
194 try {
195 err = await sendRaw($, cfg, title, text, urgent || p.kind === 'error')
196 } catch (e) {
197 err = `send failed: ${String(e)}`
198 }
199 if (err) {
200 err = redact(err)
201 await $.store.set('lastSendError', { at: now, text: err })
202 return err
203 }
204 await $.store.set('lastPageAt', now)
205 return null
206}
207
208/** A page from a hook that must not wait on the network. */
209function pageLater($: EngineInterface, cfg: PagerConfig, p: Page): void {
210 void page($, cfg, p).catch(() => undefined)
211}
212
213async function statusLine($: EngineInterface, cfg: PagerConfig): Promise<string> {
214 const [cwd, model, turns, isBusy, paused] = await Promise.all([
215 $.session.cwd(),
216 $.session.model().catch(() => '?'),
217 $.session.turns().catch(() => -1),
218 read($, busy),
219 $.store.get('paused'),
220 ])
221 const lines = [
222 `Session ${projectName(cwd)}: ${isBusy ? 'working' : 'idle'}`,
223 `Model ${model}, ${turns >= 0 ? turns : '?'} turns`,
224 `Paging ${paused === true ? 'paused (/resume)' : 'on'}${cfg.quietHours ? `, quiet hours ${cfg.quietHours}` : ''}`,
225 ]
226 return lines.join('\n')
227}
228
229/** One message from the phone: a command, or a prompt to queue. */
230export async function handleInbound($: EngineInterface, cfg: PagerConfig, raw: string): Promise<void> {
231 const text = raw.trim()
232 if (!text) return
233 const cmd = text.split(/\s+/)[0]?.toLowerCase().replace(/@.*$/, '') ?? ''
234 const reply = (label: string, body: string) => page($, cfg, { kind: 'status', label, body, force: true })
235 if (cmd === '/status') {
236 await reply('status', await statusLine($, cfg))
237 return
238 }
239 if (cmd === '/stop' || cmd === '/pause') {
240 await $.store.set('paused', true)
241 await reply('paused', 'Automatic pages are paused. Send /resume to turn them back on.')
242 return
243 }
244 if (cmd === '/resume') {
245 await $.store.set('paused', false)
246 await reply('resumed', 'Automatic pages are on again.')
247 return
248 }
249 if (cmd === '/help' || cmd === '/start') {
250 await reply('help', HELP)
251 return
252 }
253 await update($, pending, list => [...(list ?? []), text].slice(-20))
254 await $.prompt.submit({ text, asUser: true })
255 $.ui.toast(`agent-pager: prompt from the phone queued: ${clip(text, 60)}`, { timeoutMs: 6000 })
256 const isBusy = await read($, busy)
257 await reply('queued', isBusy ? 'Queued. It runs when the current turn ends.' : 'Running now.')
258}
259
260// One receiver at a time. $.store has no compare-and-set: two sessions can
261// read the same value and both write. So nothing reads the phone until the
262// person picks a session with /pager listen, and a claim is write, wait,
263// verify: each claimant writes { session, at } under 'receiver', waits at
264// least two poll intervals, and listens only if the key still names it. The
265// last write wins, so simultaneous claims settle on one session. The key is
266// written only by a claim, an unlisten and a session's end; the receiver's
267// heartbeat goes to 'beat', so it never overwrites a newer claim.
268
269type Receiver = { session: string; at: number }
270
271function asReceiver(v: unknown): Receiver | null {
272 if (!v || typeof v !== 'object') return null
273 const r = v as Partial<Receiver>
274 return typeof r.session === 'string' ? { session: r.session, at: num(r.at, 0) } : null
275}
276
277/** How long a claim waits before it checks it still holds. */
278export function claimGraceMs(cfg: PagerConfig): number {
279 return cfg.pollMs * 2 + 1000
280}
281
282/** Writes this session's claim; the first poll after the grace verifies it. */
283export async function claimReceiver($: EngineInterface): Promise<void> {
284 const [session, now] = await Promise.all([$.session.id(), $.clock.now()])
285 await $.store.set('receiver', { session, at: now })
286}
287
288/**
289 * The verify half of a claim: once the grace has passed and the receiver key
290 * still names this session, it starts listening. True when it listens.
291 */
292async function confirmClaim($: EngineInterface, cfg: PagerConfig): Promise<boolean> {
293 if (await read($, listening)) return true
294 const [session, r, now] = await Promise.all([$.session.id(), $.store.get('receiver'), $.clock.now()])
295 const rec = asReceiver(r)
296 if (!rec || rec.session !== session || now - rec.at < claimGraceMs(cfg)) return false
297 await update($, listening, () => true)
298 $.ui.toast('agent-pager: this session now receives phone messages.', { timeoutMs: 6000 })
299 return true
300}
301
302async function isReceiver($: EngineInterface): Promise<boolean> {
303 const [session, r] = await Promise.all([$.session.id(), $.store.get('receiver')])
304 return asReceiver(r)?.session === session
305}
306
307/** Stops listening; says so once when another session took over. */
308async function stopListening($: EngineInterface, why: string): Promise<void> {
309 if (!(await read($, listening))) return
310 await update($, listening, () => false)
311 $.ui.toast(`agent-pager: ${why}`, { timeoutMs: 8000 })
312}
313
314/** Gives up the receiver role, if this session holds it. */
315export async function unlisten($: EngineInterface): Promise<boolean> {
316 const owned = await isReceiver($)
317 if (owned) await $.store.delete('receiver')
318 await update($, listening, () => false)
319 return owned
320}
321
322/**
323 * Handles a message id once across sessions: false when it was already
324 * handled, else records it (before the caller submits anything).
325 */
326async function markHandled($: EngineInterface, key: string, now: number): Promise<boolean> {
327 if ((await $.store.get(key)) !== undefined) return false
328 await $.store.set(key, now)
329 return true
330}
331
332/** Drops handled-id records older than a day. */
333async function pruneHandled($: EngineInterface, now: number): Promise<void> {
334 const keys = (await $.store.keys()).filter(k => k.startsWith('handled:'))
335 if (keys.length < 200) return
336 for (const k of keys) {
337 if (now - num(await $.store.get(k), 0) > 86_400_000) await $.store.delete(k)
338 }
339}
340
341/** A failed poll: what went wrong, and a later retry. */
342class PollError extends Error {
343 retryAfterMs: number | null
344 constructor(message: string, retryAfterMs: number | null = null) {
345 super(message)
346 this.retryAfterMs = retryAfterMs
347 }
348}
349
350/** Lost the receiver role between the fetch and the submit. */
351class LostReceiver extends Error {}
352
353/** Ownership check before each message: throws when another session took over. */
354async function stillReceiver($: EngineInterface): Promise<void> {
355 if (!(await isReceiver($))) throw new LostReceiver('another session is the receiver now')
356}
357
358type TelegramUpdate = {
359 update_id: number
360 message?: { date?: number; text?: string; chat?: { id?: number | string; type?: string }; from?: { id?: number | string } }
361}
362
363export async function pollTelegram($: EngineInterface, cfg: PagerConfig): Promise<void> {
364 const offset = num(await $.store.get('tgOffset'), 0)
365 const url = `${TELEGRAM_API}/bot${cfg.telegramToken}/getUpdates?timeout=0&offset=${offset}&allowed_updates=${encodeURIComponent('["message"]')}`
366 const r = await $.http.fetch(url)
367 // Whatever arrived after the role moved on is the new receiver's.
368 await stillReceiver($)
369 let body: { ok?: boolean; result?: TelegramUpdate[]; description?: string; parameters?: { retry_after?: number } }
370 try {
371 body = JSON.parse(r.text) as typeof body
372 } catch {
373 throw new PollError(`telegram answered ${r.status} with no JSON`)
374 }
375 if (!r.ok || !body.ok) {
376 const retry = body.parameters?.retry_after
377 throw new PollError(`telegram answered ${r.status}${body.description ? `: ${body.description}` : ''}`, typeof retry === 'number' ? retry * 1000 : null)
378 }
379 const updates = (Array.isArray(body.result) ? body.result : []).slice().sort((a, b) => a.update_id - b.update_id)
380 const since = Math.floor((await read($, startedAt)) / 1000)
381 for (const u of updates) {
382 await stillReceiver($)
383 const m = u.message
384 // Private chat with the owner only: in a group anyone could write.
385 const ours =
386 !!m &&
387 typeof m.text === 'string' &&
388 m.chat?.type === 'private' &&
389 String(m.chat?.id ?? '') === cfg.telegramChatId &&
390 String(m.from?.id ?? '') === cfg.telegramChatId &&
391 (m.date ?? 0) >= since
392 if (ours && (await markHandled($, `handled:tg:${u.update_id}`, await $.clock.now()))) {
393 await handleInbound($, cfg, m.text as string)
394 }
395 // Ack right after, so Telegram stops handing this update out.
396 await $.store.set('tgOffset', Math.max(num(await $.store.get('tgOffset'), 0), u.update_id + 1))
397 }
398}
399
400type NtfyEvent = { id?: string; time?: number; event?: string; message?: string; tags?: string[] }
401
402export async function pollNtfy($: EngineInterface, cfg: PagerConfig): Promise<void> {
403 const start = Math.floor((await read($, startedAt)) / 1000)
404 const stored = await $.store.get(`ntfySince:${cfg.ntfyTopic}`)
405 const since = typeof stored === 'string' && stored ? stored : String(start)
406 const headers: Record<string, string> = {}
407 if (cfg.ntfyToken) headers.authorization = `Bearer ${cfg.ntfyToken}`
408 const r = await $.http.fetch(`${cfg.ntfyServer}/${encodeURIComponent(cfg.ntfyTopic)}/json?poll=1&since=${encodeURIComponent(since)}`, { headers })
409 await stillReceiver($)
410 if (!r.ok) {
411 const retry = Number(r.headers['retry-after'])
412 throw new PollError(`ntfy answered ${r.status}`, Number.isFinite(retry) && retry > 0 ? retry * 1000 : null)
413 }
414 for (const line of r.text.split('\n')) {
415 if (!line.trim()) continue
416 let ev: NtfyEvent
417 try {
418 ev = JSON.parse(line) as NtfyEvent
419 } catch {
420 continue
421 }
422 if (ev.event !== 'message' || !ev.id) continue
423 await stillReceiver($)
424 // A stored cursor from an earlier session can reach back past this one.
425 const ours = !ev.tags?.includes(OUT_TAG) && (ev.time ?? 0) >= start
426 if (ours && (await markHandled($, `handled:ntfy:${ev.id}`, await $.clock.now()))) {
427 await handleInbound($, cfg, ev.message ?? '')
428 }
429 await $.store.set(`ntfySince:${cfg.ntfyTopic}`, ev.id)
430 }
431}
432
433/** Sessions with a poll in flight: a tick that lands during one is skipped. */
434const polling = new Set<string>()
435
436/**
437 * One poll by the receiver, never two at once in the same session: a second
438 * call while one runs returns straight away, and the guard is released even
439 * when the poll throws. The check and the add sit together after the only
440 * await, so two calls cannot both get past it.
441 */
442export async function pollOnce($: EngineInterface, cfg: PagerConfig): Promise<void> {
443 const session = await $.session.id()
444 if (polling.has(session)) return
445 polling.add(session)
446 try {
447 await pollGuarded($, cfg)
448 } finally {
449 polling.delete(session)
450 }
451}
452
453/**
454 * Checks the role before fetching, after the fetch and before each message;
455 * a session that lost it stops listening and submits nothing more. A failure
456 * is stored for /pager status and doubles the wait, up to five minutes, or
457 * waits what the server asked.
458 */
459async function pollGuarded($: EngineInterface, cfg: PagerConfig): Promise<void> {
460 if (inboundOff(cfg)) return
461 if (!(await confirmClaim($, cfg))) return
462 if (!(await isReceiver($))) {
463 await stopListening($, 'another session now receives phone messages; this one stopped listening.')
464 return
465 }
466 const [now, session] = await Promise.all([$.clock.now(), $.session.id()])
467 await $.store.set('beat', { session, at: now })
468 if (now < num(await $.store.get('pollNextAt'), 0)) return
469 try {
470 if (cfg.backend === 'telegram') await pollTelegram($, cfg)
471 else await pollNtfy($, cfg)
472 if (num(await $.store.get('pollFailures'), 0) > 0) {
473 await $.store.set('pollFailures', 0)
474 await $.store.set('pollNextAt', 0)
475 }
476 await pruneHandled($, now)
477 } catch (e) {
478 if (e instanceof LostReceiver) {
479 await stopListening($, 'another session now receives phone messages; this one stopped listening.')
480 return
481 }
482 const failures = num(await $.store.get('pollFailures'), 0) + 1
483 const asked = e instanceof PollError ? e.retryAfterMs : null
484 const wait = Math.min(MAX_BACKOFF_MS, asked ?? cfg.pollMs * 2 ** failures)
485 const text = redact(e instanceof Error ? e.message : String(e))
486 await $.store.set('pollFailures', failures)
487 await $.store.set('pollNextAt', now + wait)
488 await $.store.set('lastPollError', { at: now, text })
489 }
490}
491
492/** Starts the poll timer once per copy of the module, in an interactive session. */
493async function ensurePoller($: EngineInterface, cfg: PagerConfig): Promise<void> {
494 if (pollerRunning || inboundOff(cfg)) return
495 if (!(await read($, interactive))) return
496 pollerRunning = true
497 $.clock.every(cfg.pollMs, () => {
498 void pollOnce($, cfg).catch(() => undefined)
499 })
500}
501
502/** Who receives phone messages, as /pager status says it. */
503async function receiverLine($: EngineInterface, cfg: PagerConfig, now: number): Promise<string> {
504 const off = inboundOff(cfg)
505 if (off) return `Replies from the phone off (${off})`
506 const [session, r, beat, isListening] = await Promise.all([$.session.id(), $.store.get('receiver'), $.store.get('beat'), read($, listening)])
507 const rec = asReceiver(r)
508 if (!rec) return 'Replies from the phone: no session is listening. Run /pager listen in the session that should receive them.'
509 const b = asReceiver(beat)
510 const seen = b && b.session === rec.session ? `, last poll ${duration(now - b.at)} ago` : ''
511 if (rec.session === session) {
512 return isListening
513 ? `Replies from the phone: this session is the receiver, checking every ${cfg.pollMs / 1000}s${seen}`
514 : `Replies from the phone: this session claimed the receiver role; confirming within ${duration(claimGraceMs(cfg))}`
515 }
516 return `Replies from the phone go to another session (${rec.session.slice(0, 8)}${seen}). /pager listen moves them here.`
517}
518
519async function pagerStatus($: EngineInterface, cfg: PagerConfig): Promise<string> {
520 const [paused, lastAt, now, pollErr, sendErr, nextAt] = await Promise.all([
521 $.store.get('paused'),
522 $.store.get('lastPageAt'),
523 $.clock.now(),
524 $.store.get('lastPollError'),
525 $.store.get('lastSendError'),
526 $.store.get('pollNextAt'),
527 ])
528 const why = missing(cfg)
529 const target =
530 cfg.backend === 'ntfy' ? `ntfy topic "${mask(cfg.ntfyTopic)}" on ${cfg.ntfyServer}` : cfg.backend === 'telegram' ? `Telegram chat ${cfg.telegramChatId}` : 'nowhere'
531 const last = typeof lastAt === 'number' ? `${duration(now - lastAt)} ago` : 'never'
532 const quiet = !cfg.quietHours ? 'none' : !parseQuietHours(cfg.quietHours) ? `"${cfg.quietHours}" is invalid, ignored (use 22-07 or 22:30-06:45)` : `${cfg.quietHours}${inQuietHours(cfg.quietHours, now) ? ' (now)' : ''}`
533 const lines = [
534 `agent-pager: ${why ? `not sending (${why})` : `pages go to ${target}`}`,
535 `Paging ${paused === true ? 'paused' : 'on'}; last page ${last}`,
536 `Turns longer than ${cfg.minTurnMs / 1000}s page; questions after ${cfg.askDelayMs / 1000}s; gap ${cfg.minGapMs / 1000}s (questions and permissions skip it)`,
537 `Quiet hours ${quiet}; private mode ${cfg.privateMode ? 'on' : 'off'}; errors ${cfg.notifyErrors ? 'on' : 'off'}`,
538 await receiverLine($, cfg, now),
539 ]
540 const err = (v: unknown) => (v && typeof v === 'object' && 'text' in v && 'at' in v ? (v as { at: number; text: string }) : null)
541 const pe = err(pollErr)
542 if (pe) lines.push(`Last poll error ${duration(now - pe.at)} ago: ${pe.text}${num(nextAt, 0) > now ? `; next try in ${duration(num(nextAt, 0) - now)}` : ''}`)
543 const se = err(sendErr)
544 if (se) lines.push(`Last send error ${duration(now - se.at)} ago: ${se.text}`)
545 lines.push('Tool calls are never approved from the phone.')
546 return lines.join('\n')
547}
548
549type AskQuestion = { question?: string; options?: { label?: string }[] }
550
551export function questionBody(questions: unknown): string {
552 const qs = Array.isArray(questions) ? (questions as AskQuestion[]) : []
553 const first = qs[0]
554 if (!first) return 'A question is waiting in the terminal.'
555 const opts = (first.options ?? []).map(o => o.label ?? '').filter(Boolean)
556 const more = qs.length > 1 ? ` (+${qs.length - 1} more)` : ''
557 return clip(`${first.question ?? ''}${more}${opts.length ? ` Options: ${opts.join(' / ')}` : ''}`, BODY_CHARS)
558}
559
560export const register: Register = (on, options) => {
561 const cfg = readConfig(options)
562
563 on('session.start', async ($, e, next) => {
564 const now = await $.clock.now()
565 await update($, startedAt, () => now)
566 await update($, interactive, () => e.isInteractive)
567 try {
568 await $.command.register({
569 name: 'pager',
570 description: 'agent-pager: status, test, pause, resume, listen, unlisten',
571 argumentHint: '[status|test|pause|resume|listen|unlisten]',
572 })
573 } catch {
574 // the poller and the pages do not depend on the command
575 }
576 await ensurePoller($, cfg)
577 return next(e)
578 })
579
580 on('session.end', async ($, e, next) => {
581 try {
582 if (asReceiver(await $.store.get('receiver'))?.session === e.sessionId) await $.store.delete('receiver')
583 } catch {
584 // the next /pager listen replaces a stale claim anyway
585 }
586 return next(e)
587 })
588
589 on('command.run', { command: 'pager' }, async ($, e) => {
590 await ensurePoller($, cfg)
591 const arg = e.args.trim().toLowerCase()
592 if (arg === 'pause' || arg === 'stop') {
593 await $.store.set('paused', true)
594 return { text: 'agent-pager: automatic pages paused. /pager resume turns them back on.' }
595 }
596 if (arg === 'resume') {
597 await $.store.set('paused', false)
598 return { text: 'agent-pager: automatic pages on.' }
599 }
600 if (arg === 'test') {
601 const why = await page($, cfg, { kind: 'test', label: 'test page', body: 'If you can read this, agent-pager reaches your phone.', force: true })
602 return { text: why ? `agent-pager: test page not sent (${why}).` : 'agent-pager: test page sent.' }
603 }
604 if (arg === 'listen') {
605 const off = inboundOff(cfg)
606 if (off) return { text: `agent-pager: cannot listen (${off}).` }
607 if ((await read($, listening)) && (await isReceiver($))) return { text: 'agent-pager: this session already receives phone messages.' }
608 await claimReceiver($)
609 return {
610 text: `agent-pager: claimed the receiver role. This session starts reading phone messages in about ${duration(claimGraceMs(cfg))}, unless another session claims it meanwhile; /pager status shows the outcome.`,
611 }
612 }
613 if (arg === 'unlisten') {
614 const owned = await unlisten($)
615 return { text: owned ? 'agent-pager: stopped listening. No session reads phone messages until one runs /pager listen.' : 'agent-pager: this session was not the receiver.' }
616 }
617 if (arg && arg !== 'status') return { text: 'Usage: /pager [status|test|pause|resume|listen|unlisten]' }
618 return { text: await pagerStatus($, cfg) }
619 })
620
621 // turn.start fires for the main loop only (a subagent's run raises none).
622 on('turn.start', async ($, e, next) => {
623 await update($, busy, () => true)
624 const waiting = await read($, pending)
625 const i = waiting.findIndex(t => t === e.text.trim() || e.text.includes(t))
626 if (i >= 0) {
627 await update($, pending, list => (list ?? []).filter((_, j) => j !== i))
628 await update($, remoteTurns, list => [...(list ?? []), e.turnId].slice(-20))
629 }
630 await ensurePoller($, cfg)
631 return next(e)
632 })
633
634 on('turn.complete', async ($, e, next) => {
635 const result = await next(e)
636 // A subagent's turn ends inside the main turn: it neither clears busy
637 // nor pages.
638 if (e.agentId) return result
639 await update($, busy, () => false)
640 const fromPhone = (await read($, remoteTurns)).includes(e.turnId)
641 if (fromPhone) await update($, remoteTurns, list => (list ?? []).filter(id => id !== e.turnId))
642 if (e.reason === 'aborted') return result
643 const took = duration(e.durationMs)
644 if (e.reason === 'error' || e.reason === 'refusal') {
645 if (cfg.notifyErrors || fromPhone) {
646 pageLater($, cfg, {
647 kind: 'error',
648 label: `Turn ended on ${e.reason === 'error' ? 'an error' : 'a refusal'} (${took})`,
649 body: clip(e.answer || 'No reply text.', BODY_CHARS),
650 force: fromPhone,
651 })
652 }
653 return result
654 }
655 if (fromPhone || e.durationMs >= cfg.minTurnMs) {
656 pageLater($, cfg, {
657 kind: fromPhone ? 'reply' : 'turn',
658 label: `Turn finished (${took})`,
659 body: clip(e.answer || 'No reply text.', BODY_CHARS),
660 force: fromPhone,
661 })
662 }
663 return result
664 })
665
666 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
667 const body = questionBody(e.questions)
668 const timer = $.clock.after(Math.max(1, cfg.askDelayMs), () => {
669 pageLater($, cfg, { kind: 'question', label: 'Question waiting', body })
670 })
671 try {
672 return await next(e)
673 } finally {
674 timer.cancel()
675 }
676 }).catch(($, e, next) => next(e))
677
678 on('classic.Notification', async ($, e, next) => {
679 const result = await next(e)
680 const kind = e.notification_type
681 if (kind === 'permission_prompt') {
682 pageLater($, cfg, { kind: 'permission', label: 'Permission needed', body: clip(`${e.message} Approve it in the terminal.`, BODY_CHARS) })
683 } else if (kind === 'elicitation_dialog' || kind === 'elicitation_url_dialog') {
684 pageLater($, cfg, { kind: 'question', label: 'Input needed', body: clip(e.message, BODY_CHARS) })
685 }
686 return result
687 }).catch(($, e, next) => next(e))
688}
689types/index.d.ts 58 lines1// agent-pager: the session state it keeps in $.state, and its config shape.
2//
3// Type-check recipe (keeps tsconfig out of the repo). From any folder:
4// mkdir -p /tmp/agent-pager-tsc && cat > /tmp/agent-pager-tsc/tsconfig.json <<JSON
5// {
6// "compilerOptions": {
7// "target": "es2023", "lib": ["es2023"], "types": [],
8// "module": "esnext", "moduleResolution": "bundler",
9// "strict": true, "noUncheckedIndexedAccess": true,
10// "noEmit": true, "skipLibCheck": true,
11// "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment"
12// },
13// "include": ["<repo>/hooks", "<repo>/types", "<repo>/.claude-plugin/types"]
14// }
15// JSON
16// npx -y -p typescript@5.6.3 tsc -p /tmp/agent-pager-tsc
17// <repo>/.claude-plugin/types is written by Claude Code each time it loads the
18// mod with --plugin-dir (git ignores it). Without it, add the declaration file
19// from the plugin-authoring skill (types/claude-code.d.ts) to "include".
20
21export type Backend = 'ntfy' | 'telegram' | 'off'
22
23export type PagerConfig = {
24 backend: Backend
25 ntfyTopic: string
26 ntfyServer: string
27 ntfyToken: string
28 ntfyInbound: boolean
29 telegramToken: string
30 telegramChatId: string
31 inbound: boolean
32 pollMs: number
33 minTurnMs: number
34 askDelayMs: number
35 minGapMs: number
36 quietHours: string
37 privateMode: boolean
38 notifyErrors: boolean
39}
40
41export type PageKind = 'turn' | 'question' | 'permission' | 'error' | 'test' | 'reply' | 'status'
42
43export type Page = {
44 kind: PageKind
45 /** Short label after the project name, like "Turn finished (2m 5s)". */
46 label: string
47 /** The content: reply excerpt, question text. Left out in private mode. */
48 body: string
49 /** Bypass pause, quiet hours and the gap: tests and answers to the phone. */
50 force?: boolean
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'agent-pager': { busy: boolean; startedAt: number; interactive: boolean; pending: string[]; remoteTurns: string[]; listening: boolean }
56 }
57}
58