SLOPSHOPPER

agent-pager

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

newguardcommandtoastnetworktimer
v0.1.0MITupdated 2026-10-09RadTech-Solutions/agent-pager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-pager
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pager ⎿ agent-pager: agent-pager: not sending (ntfy_topic is not set) ⎿ agent-pager: Paging on; last page never ⎿ agent-pager: Turns longer than 60s page; questions after 10s; gap 30s (questions and permissions skip it) ⎿ agent-pager: Quiet hours none; private mode off; errors on ⎿ agent-pager: Replies from the phone off (ntfy_topic is not set) ⎿ agent-pager: Tool calls are never approved from the phone. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agent-pager

Your phone buzzes when the coding agent needs you, and you can answer from the phone.

Overview

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:

  • a turn finishes after running longer than a threshold (60 seconds by default). The page carries the project folder name, the duration and the first 200 characters of the reply.
  • the agent waits on you: an 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.
  • a turn ends on an API error or a refusal (optional, on by default).

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.

Set up

ntfy in one minute

  1. Install the ntfy app (Android, iOS) or open https://ntfy.sh/app in a browser.
  2. Pick a long random topic name, for example 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.
  3. Subscribe to that topic in the app.
  4. Set 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.

Telegram bot via BotFather

  1. In Telegram, open a chat with @BotFather, send /newbot and follow the questions. Copy the token it gives you (123456789:AA...).
  2. Open a chat with your new bot and send it any message.
  3. Find your chat id: open https://api.telegram.org/bot<TOKEN>/getUpdates in a browser and read message.chat.id from the answer.
  4. Set backend to telegram, telegram_token to the token and telegram_chat_id to the id. Run /pager test.
  5. Run /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.

Download and install

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.

Options

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

Usage

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.

One receiver, chosen by you

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:

  • A handoff takes about 11 seconds (two poll intervals plus a second). Messages sent in that window are read by the new receiver once it starts.
  • Ending the receiver session gives the role up. A session that crashes keeps it on record until another session runs /pager listen; nothing takes over on its own.
  • The handled check and the role check are separate store reads and writes, not one atomic step. The tests drive two overlapping pollers over one shared store and show one submit per message, and a run of two real sessions showed that a store write from one is visible to the other within a fraction of a second. A duplicate would need a session to stall for more than the whole grace period between its role check and its handled check.

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.

Security and privacy

  • No remote approvals. agent-pager never approves or denies a tool call. A permission prompt is announced on the phone and must be answered in the terminal. This is a deliberate choice for v1: a leaked bot token or topic name should not be able to run commands without your consent at the keyboard.
  • Phone prompts are your own words. A message from the configured Telegram chat is submitted as if you typed it, so it runs under the session's normal permission rules. Anyone who controls that chat can prompt your agent. Keep the bot token secret.
  • ntfy topics are public by name. Pages on ntfy.sh can be read by anyone who guesses the topic. Use a long random name, a self-hosted server with an access token, or private_mode. /pager status shows only the first four characters of the topic.
  • ntfy replies need a token. ntfy_inbound does nothing without ntfy_token, since an open topic would let anyone prompt your agent.
  • Telegram groups are ignored. Only a private chat whose chat id and sender id both match telegram_chat_id is read.
  • Private mode replaces every automatic page, and the reply sent back for a prompt from the phone, with "Your agent needs you." It drops the project name, the reply text and the question. /pager test and /status replies still name the project.
  • What leaves the machine. Without private mode: the project folder name, the first 200 characters of a reply or question, and the permission prompt's message. Nothing else from the conversation is sent.
  • Secrets. 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.

Tests

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.

Built by RadTech

RadTech builds apps, web products and applied AI, and advises founders. We do AI advisory, building and consulting.

Hire us: https://cal.com/radtech-solutions-yjxizt/15min

Source 2 files
hooks/register.tsx 689 lines
1import { 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}
689
types/index.d.ts 58 lines
1// 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