Pager: Claude Code pings you in Telegram when a task is done or it waits for your permission, and you text back the next prompt, /status, /last or /stop from…

Claude Code in your pocket: a Telegram message when a task is done or Claude waits for your permission — and you text back the next prompt.
A mod (function-hooks plugin) for Claude Code. No server, no Bun, no extra flags: the mod talks to your own Telegram bot through Claude Code's network, and only the chat you paired can talk back.
<img src="docs/img/01-chat.png" width="320" alt="A prompt texted from the phone and the finished answer back in Telegram">
/last sends the whole one)./status — idle, or what Claude is working on and for how long/last — the last answer in full/stop — stop the running task✅ Done · 📁 galleria · 3 min 05 s
All 49 unit tests pass. I fixed the cache: the museum's API sent `Vary: *`…
| any text | queued into the session as your own prompt |
/status | idle, or what Claude is working on and for how long |
/last | the last answer in full |
/stop | stop the running task |
/help | the list above |
The bot's Menu button lists them too — Pager fills it in on start and on pairing.
<img src="docs/img/02-commands.png" width="300" alt="/status while idle, /last repeating the whole answer, /stop with nothing running"> <img src="docs/img/03-status-stop.png" width="300" alt="A prompt from Telegram, /status showing the running task, /stop ending it">
/plugin install pager --marketplace vihrea1337/pager
Answer y to add the marketplace, pick the user scope, and paste your bot token when asked (it goes to Claude Code's secure storage).
/newbot → a name → a username ending in bot. Copy the token it sends (1234567890:AA…)./pager token <token> in Claude Code./pager pair <code>. Done — /pager test sends a test message.From then on the bot listens to that chat only; messages from anyone else are dropped silently.
/config → pager)| Setting | Default | |
|---|---|---|
| Notify after (seconds) | 0 | Every turn is reported. Raise it and quick answers you watched yourself stop buzzing your phone; turns started from Telegram are always reported. |
| Notify when waiting | on | The "waiting for permission / input" message |
| Accept prompts from Telegram | on | Off = notifications only |
| Desktop notification too | off | Also a system notification on this computer |
| Language | en | en or ru |
/pager alone shows what is set up: token, paired chat, which session is listening.
A message to your bot becomes a prompt in a session that can edit files and run commands, so:
/pager token); nobody else should see it — if it leaks, /revoke it in @BotFather;| Hook | What for |
|---|---|
turn.start / turn.complete | remembers what is running; on completion sends the answer (e.answer, e.durationMs, e.reason) |
classic.Notification | permission / input prompts → "waiting for you" (with a .catch that always passes the engine's own notification on) |
session.start | registers /pager, starts the poll timer ($.clock.every) |
timer → $.http.fetch | Telegram getUpdates long-poll; text → $.prompt.submit({ asUser: true }); /stop → $.turn.abort |
session.end | hands the polling lease back |
All logic is pure functions in hooks/core.ts (message formatting, update parsing, the lease); hooks/register.ts wires them to the engine. Tests run against the engine's own test kit with Telegram and the store faked: claude plugin test .
claude plugin validate .
claude plugin test .
claude --plugin-dir . # a session with the mod loaded from this folder
Requires Claude Code 2.1.287 or newer (function-hooks mods).
Claude Code's research-preview channels also bridge Telegram, as an MCP server on Bun started with --channels. Pager is a lighter take on the other half: it notifies you first (a channel only answers what you send), needs nothing but the mod, and works in every session you start. Don't point both at the same bot — Telegram lets one reader poll it.
MIT
hooks/register.ts 410 lines1// Pager — the hooks. Logic lives in core.ts; this file talks to the engine and to Telegram.
2//
3// turn.complete → "✅ Done · 📁 project · 3 min" + the answer, to the paired chat
4// classic.Notification → "⏸ Waiting for you" (permission prompts and the like)
5// a timer (session.start) → long-polls the bot: text → $.prompt.submit, /status /last /stop /help
6// /pager → token <token> · pair <code> · status · test · unpair
7//
8// Network goes through $.http.fetch (the host's network); nothing is read or written on disk
9// but the mod's own store (paired chat, poll offset, the token if set with /pager token).
10
11import type { EngineInterface, PluginOptions, Register } from 'claude-code'
12import {
13 type Lang,
14 type Lease,
15 type TgUpdate,
16 LEASE_MS,
17 MAX_ANSWER,
18 PAIRING_TTL_MS,
19 classify,
20 doneMessages,
21 escapeHtml,
22 htmlToPlain,
23 looksLikeToken,
24 markdownToHtml,
25 mayPoll,
26 pairingCode,
27 projectName,
28 shorten,
29 splitMarkdown,
30 statusMessage,
31 waitingMessage,
32 words,
33} from './core'
34
35const API = 'https://api.telegram.org'
36/** getUpdates long-poll: Telegram holds the request up to this long when nothing comes. */
37const POLL_SECONDS = 20
38
39/** The module's own memory; a reload starts it over, the store keeps what must survive. */
40type Ctx = {
41 options: PluginOptions
42 lang: Lang
43 sessionId: string
44 project: string
45 running?: { turnId: string; since: number; task: string }
46 /** What Claude wrote between tool calls this turn: shown under "Along the way" in "Done". */
47 notes: string[]
48 lastAnswer: string
49 /** Texts submitted from Telegram: their turns are always reported. */
50 remoteTexts: Set<string>
51 remoteTurn: boolean
52 /** The next turn was started by a background task's notification, not by a person. */
53 taskPrompt: boolean
54 taskTurn: boolean
55 /** Background tasks still running when the last turn stopped (classic Stop's background_tasks). */
56 background: number
57 pending?: { code: string; chatId: number; until: number }
58 polling: boolean
59 warnedToken: boolean
60}
61
62// --- store -----------------------------------------------------------------------------------
63
64async function botToken($: EngineInterface, ctx: Ctx): Promise<string> {
65 const fromOptions = String(ctx.options.botToken ?? '').trim()
66 if (fromOptions !== '') return fromOptions
67 return String((await $.store.get('token')) ?? '').trim()
68}
69
70async function pairedChat($: EngineInterface): Promise<number | undefined> {
71 const id = await $.store.get('chatId')
72 return typeof id === 'number' ? id : undefined
73}
74
75// --- Telegram --------------------------------------------------------------------------------
76
77async function tg($: EngineInterface, ctx: Ctx, method: string, body: Record<string, unknown>): Promise<any> {
78 const token = await botToken($, ctx)
79 if (token === '') return undefined
80 const res = await $.http.fetch(`${API}/bot${token}/${method}`, {
81 method: 'POST',
82 headers: { 'content-type': 'application/json' },
83 body: JSON.stringify(body),
84 })
85 let data: any
86 try {
87 data = JSON.parse(res.text)
88 } catch {
89 data = undefined
90 }
91 if (!res.ok || !data?.ok) throw new Error(`telegram ${method}: ${res.status} ${data?.description ?? ''}`.trim())
92 return data.result
93}
94
95async function send($: EngineInterface, ctx: Ctx, chatId: number, html: string): Promise<void> {
96 try {
97 await tg($, ctx, 'sendMessage', { chat_id: chatId, text: html, parse_mode: 'HTML', disable_web_page_preview: true })
98 } catch (err) {
99 // 400 "can't parse entities": the same words without markup still get through.
100 if (/400/.test(String(err))) {
101 try {
102 await tg($, ctx, 'sendMessage', { chat_id: chatId, text: htmlToPlain(html).slice(0, 4096), disable_web_page_preview: true })
103 return
104 } catch (again) {
105 err = again
106 }
107 }
108 $.ui.toast(`pager: ${String(err).slice(0, 120)}`)
109 }
110}
111
112/** The bot's "Menu" button: the commands it answers, in the chosen language. */
113async function setCommands($: EngineInterface, ctx: Ctx): Promise<void> {
114 const c = words(ctx.lang).commands
115 try {
116 await tg($, ctx, 'setMyCommands', {
117 commands: Object.entries(c).map(([command, description]) => ({ command, description })),
118 })
119 } catch {}
120}
121
122async function notifyPaired($: EngineInterface, ctx: Ctx, html: string | readonly string[]): Promise<void> {
123 const chatId = await pairedChat($)
124 if (chatId === undefined) return
125 for (const part of typeof html === 'string' ? [html] : html) await send($, ctx, chatId, part)
126}
127
128// --- polling (one session at a time holds the lease) -----------------------------------------
129
130async function poll($: EngineInterface, ctx: Ctx): Promise<void> {
131 if (ctx.polling) return
132 ctx.polling = true
133 try {
134 if ((await botToken($, ctx)) === '') {
135 if (!ctx.warnedToken) {
136 ctx.warnedToken = true
137 $.ui.toast('pager: no bot token yet — /pager token <token> (or /config)')
138 }
139 return
140 }
141 const now = await $.clock.now()
142 const lease = (await $.store.get('lease')) as Lease | undefined
143 if (!mayPoll(lease, ctx.sessionId, now)) return
144 await $.store.set('lease', { sessionId: ctx.sessionId, until: now + LEASE_MS } satisfies Lease)
145
146 const offset = Number((await $.store.get('offset')) ?? 0)
147 const updates = ((await tg($, ctx, 'getUpdates', {
148 offset,
149 timeout: POLL_SECONDS,
150 allowed_updates: ['message'],
151 })) ?? []) as TgUpdate[]
152 for (const u of updates) {
153 await $.store.set('offset', u.update_id + 1)
154 await handle($, ctx, u)
155 }
156 } catch {
157 // A network hiccup or Telegram's 409 (another poller): try again next period.
158 } finally {
159 ctx.polling = false
160 }
161}
162
163async function handle($: EngineInterface, ctx: Ctx, update: TgUpdate): Promise<void> {
164 const w = words(ctx.lang)
165 const incoming = classify(update, await pairedChat($))
166 switch (incoming.kind) {
167 case 'ignore':
168 return
169 case 'pair-request': {
170 const code = pairingCode(Math.random())
171 ctx.pending = { code, chatId: incoming.chatId, until: (await $.clock.now()) + PAIRING_TTL_MS }
172 await send($, ctx, incoming.chatId, w.pairHello(code))
173 $.ui.toast(`pager: @${incoming.name} asks to pair — /pager pair <code from Telegram>`, { timeoutMs: 15_000 })
174 return
175 }
176 case 'prompt': {
177 if (ctx.options.acceptPrompts === false) return send($, ctx, incoming.chatId, w.promptsOff)
178 ctx.remoteTexts.add(incoming.text)
179 await send($, ctx, incoming.chatId, ctx.running ? w.queued : w.sent)
180 void $.prompt.submit({ text: incoming.text, asUser: true })
181 return
182 }
183 case 'command':
184 return botCommand($, ctx, incoming.chatId, incoming.name)
185 }
186}
187
188async function botCommand($: EngineInterface, ctx: Ctx, chatId: number, name: string): Promise<void> {
189 const w = words(ctx.lang)
190 switch (name) {
191 case 'start':
192 case 'help':
193 return send($, ctx, chatId, w.help)
194 case 'status':
195 return send(
196 $,
197 ctx,
198 chatId,
199 statusMessage({
200 runningSinceMs: ctx.running?.since,
201 nowMs: await $.clock.now(),
202 task: ctx.running?.task,
203 background: ctx.running ? undefined : ctx.background,
204 lang: ctx.lang,
205 }),
206 )
207 case 'last': {
208 if (ctx.lastAnswer.trim() === '') return send($, ctx, chatId, w.noLast)
209 // The whole answer, in as many messages as it takes.
210 for (const part of splitMarkdown(ctx.lastAnswer, MAX_ANSWER)) await send($, ctx, chatId, markdownToHtml(part))
211 return
212 }
213 case 'stop': {
214 if (!ctx.running) return send($, ctx, chatId, w.nothingToStop)
215 try {
216 await $.turn.abort({ turnId: ctx.running.turnId })
217 return send($, ctx, chatId, w.stoppedTurn)
218 } catch {
219 return send($, ctx, chatId, w.nothingToStop)
220 }
221 }
222 default:
223 return send($, ctx, chatId, w.unknown(name))
224 }
225}
226
227/** `/pager` in the terminal. */
228async function pagerCommand($: EngineInterface, ctx: Ctx, args: string): Promise<string> {
229 const w = words(ctx.lang)
230 const [sub = 'status', ...rest] = args.trim().split(/\s+/)
231 const arg = rest.join(' ').trim()
232 switch (sub) {
233 case 'token': {
234 if (!looksLikeToken(arg)) return 'pager: that does not look like a bot token (1234567890:AA...).'
235 await $.store.set('token', arg)
236 ctx.warnedToken = false
237 try {
238 const me = await tg($, ctx, 'getMe', {})
239 await setCommands($, ctx)
240 return `pager: token saved — bot @${me?.username}. Now text the bot anything; it answers with a code for /pager pair <code>.`
241 } catch (err) {
242 return `pager: token saved, but Telegram refused it: ${String(err)}`
243 }
244 }
245 case 'pair': {
246 const now = await $.clock.now()
247 if (!ctx.pending || ctx.pending.until < now) return 'pager: no pairing in progress — text the bot first, it answers with a code.'
248 if (arg !== ctx.pending.code) return 'pager: wrong code.'
249 await $.store.set('chatId', ctx.pending.chatId)
250 await send($, ctx, ctx.pending.chatId, w.paired)
251 await setCommands($, ctx)
252 ctx.pending = undefined
253 return 'pager: paired — notifications go to that chat, and only it can send prompts.'
254 }
255 case 'unpair':
256 // `delete`, not `set(key, undefined)`: the store takes JSON data, and
257 // `undefined` is not — the set rejected, the command answered nothing, and
258 // the engine said "no command.run hook answered it" (found live 2026-10-11).
259 await $.store.delete('chatId')
260 return 'pager: unpaired. Text the bot again to pair a chat.'
261 case 'test': {
262 const chatId = await pairedChat($)
263 if (chatId === undefined) return 'pager: no chat paired yet.'
264 for (const part of doneMessages({ project: ctx.project, reason: 'answer', durationMs: 75_000, answer: 'Test from Pager 👋', lang: ctx.lang })) {
265 await send($, ctx, chatId, part)
266 }
267 return 'pager: test message sent.'
268 }
269 default: {
270 const token = await botToken($, ctx)
271 const chatId = await pairedChat($)
272 const lease = (await $.store.get('lease')) as Lease | undefined
273 const where = ctx.options.acceptPrompts === false ? 'off' : lease?.sessionId === ctx.sessionId ? 'in this session' : 'in another session'
274 return [
275 `pager: token ${token ? 'set' : 'missing — /pager token <token>'}`,
276 `chat ${chatId !== undefined ? 'paired' : 'not paired — text the bot, then /pager pair <code>'}`,
277 `listening ${where}`,
278 `notify after ${Number(ctx.options.minSeconds ?? 0)} s · language ${ctx.lang}`,
279 ].join('\n')
280 }
281 }
282}
283
284async function releaseLease($: EngineInterface, ctx: Ctx): Promise<void> {
285 try {
286 const lease = (await $.store.get('lease')) as Lease | undefined
287 if (lease?.sessionId === ctx.sessionId) await $.store.delete('lease')
288 } catch {}
289}
290
291async function reportTurn(
292 $: EngineInterface,
293 ctx: Ctx,
294 turn: { reason: 'answer' | 'aborted' | 'refusal' | 'error'; durationMs: number; answer: string; notes: readonly string[] },
295): Promise<void> {
296 const minMs = Math.max(0, Number(ctx.options.minSeconds ?? 0)) * 1000
297 if (turn.durationMs < minMs && !ctx.remoteTurn && !ctx.taskTurn) return
298 await notifyPaired(
299 $,
300 ctx,
301 doneMessages({ project: ctx.project, ...turn, lang: ctx.lang, afterTask: ctx.taskTurn, background: ctx.background }),
302 )
303 if (ctx.options.desktop === true) {
304 await $.ui.notify(shorten(turn.answer.trim() || words(ctx.lang).done, 180).text, { title: `Claude · ${ctx.project}` })
305 }
306}
307
308// --- hooks -----------------------------------------------------------------------------------
309
310export const register: Register = (on, options) => {
311 const ctx: Ctx = {
312 options,
313 lang: options.language === 'ru' ? 'ru' : 'en',
314 sessionId: '',
315 project: '',
316 lastAnswer: '',
317 notes: [],
318 remoteTexts: new Set(),
319 remoteTurn: false,
320 taskPrompt: false,
321 taskTurn: false,
322 background: 0,
323 polling: false,
324 warnedToken: false,
325 }
326
327 on('session.start', async ($, e, next) => {
328 ctx.sessionId = await $.session.id()
329 ctx.project = projectName(e.cwd)
330 await $.command.register({
331 name: 'pager',
332 description: 'Telegram pager: token <token> · pair <code> · status · test · unpair',
333 argumentHint: 'token <token> | pair <code> | status | test | unpair',
334 })
335 if (e.isInteractive && options.acceptPrompts !== false) {
336 // One period = one long-poll at most; ctx.polling keeps them from overlapping.
337 $.clock.every(1500, () => void poll($, ctx))
338 void setCommands($, ctx)
339 }
340 return next(e)
341 })
342
343 on('session.end', async ($, e, next) => {
344 // Hand the lease back so another open session can take the bot at once.
345 await releaseLease($, ctx)
346 return next(e)
347 })
348
349 on('prompt.submit', ($, e, next) => {
350 // Only an idle session's notification starts a turn of its own; one delivered into a running turn does not.
351 if (e.origin.kind === 'task-notification' && e.turnId === undefined) ctx.taskPrompt = true
352 return next(e)
353 }).catch(($, e, next) => next(e))
354
355 on('turn.start', ($, e, next) => {
356 ctx.running = { turnId: e.turnId, since: Date.now(), task: e.text }
357 ctx.remoteTurn = ctx.remoteTexts.delete(e.text)
358 ctx.taskTurn = ctx.taskPrompt
359 ctx.taskPrompt = false
360 ctx.notes = []
361 return next(e)
362 })
363
364 // Text written before a tool call is not the turn's answer (turn.complete carries only the last);
365 // keep it so "Done" can show it. A subagent's steps carry its own run's id and are left out.
366 on('turn.step', async function* ($, e, next) {
367 const result = yield* next(e)
368 if (result.toolUses.length > 0 && result.answer.trim() !== '' && e.turnId === ctx.running?.turnId) {
369 ctx.notes.push(result.answer)
370 }
371 return result
372 })
373
374 // Stop is where the engine lists background work still in flight; it runs before turn.complete's report.
375 on('classic.Stop', ($, e, next) => {
376 ctx.background = e.background_tasks?.length ?? 0
377 return next(e)
378 }).catch(($, e, next) => next(e))
379
380 on('turn.complete', async ($, e, next) => {
381 const result = await next(e)
382 if (e.agentId !== undefined) return result // a subagent's run, not the session's turn
383 ctx.running = undefined
384 ctx.lastAnswer = e.answer
385 void reportTurn($, ctx, { reason: e.reason, durationMs: e.durationMs, answer: e.answer, notes: ctx.notes })
386 ctx.notes = []
387 ctx.remoteTurn = false
388 ctx.taskTurn = false
389 return result
390 })
391
392 on('classic.Notification', async ($, e, next) => {
393 if (options.notifyPermission !== false && /permission|input|waiting/i.test(`${e.notification_type} ${e.message}`)) {
394 void notifyPaired($, ctx, waitingMessage({ project: ctx.project, message: e.message, lang: ctx.lang }))
395 }
396 return next(e)
397 }).catch(($, e, next) => next(e)) // a pager that failed must never swallow the engine's own notification
398
399 // Always answers, even when something inside throws: an unanswered command.run
400 // leaves the person with the engine's "no hook answered it", which says nothing
401 // about what went wrong.
402 on('command.run', { command: 'pager' }, async ($, e) => {
403 try {
404 return { text: await pagerCommand($, ctx, e.args) }
405 } catch (err) {
406 return { text: `pager: ${String(err).slice(0, 300)}` }
407 }
408 })
409}
410hooks/core.ts 294 lines1// Pager: all the logic as pure functions — formatting, parsing Telegram updates,
2// the polling lease. register.tsx wires them to the engine; tests call them directly.
3
4export type Lang = 'en' | 'ru'
5
6/** Telegram allows 4096 characters per message; one part of an answer leaves room for the header and tags. */
7export const MAX_ANSWER = 3500
8
9const WORDS = {
10 en: {
11 done: 'Done',
12 stopped: 'Stopped',
13 failed: 'Ended with an error',
14 refused: 'Refused',
15 waiting: 'Waiting for you',
16 noAnswer: '(no text answer)',
17 along: 'Along the way:',
18 result: 'Result:',
19 queued: '📨 Queued — Claude takes it when the current task is done.',
20 sent: '📨 Sent to the session.',
21 pairHello: (code: string) =>
22 `👋 To link this chat, type in Claude Code:\n<code>/pager pair ${code}</code>\n\nThe code works for 10 minutes.`,
23 paired: '✅ Paired. Text me the next prompt, or /help.',
24 idle: '💤 Idle — waiting for a prompt.',
25 idleBackground: (n: number) => `💤 Idle · ${n} background task${n === 1 ? '' : 's'} running — Claude picks up when ${n === 1 ? 'it ends' : 'they end'}.`,
26 backgroundNote: (n: number) => `\n\n<i>+ ${n} background task${n === 1 ? '' : 's'} running</i>`,
27 afterTask: 'after a background task',
28 working: (s: string, task: string) => `⏳ Working for ${s} on:\n<i>${task}</i>`,
29 nothingToStop: 'Nothing is running.',
30 stoppedTurn: '⏹ Stopped the running task.',
31 noLast: 'No answer yet in this session.',
32 promptsOff: 'Prompts from Telegram are turned off in this session (/config → pager).',
33 help:
34 '<b>Pager</b> — this chat is linked to a Claude Code session.\n\n' +
35 'Any text — queued as the next prompt\n' +
36 '/status — what Claude is doing\n' +
37 '/last — the last answer in full\n' +
38 '/stop — stop the running task\n' +
39 '/help — this message',
40 unknown: (c: string) => `Unknown command /${c} — /help`,
41 commands: { status: 'What Claude is doing', last: 'The last answer in full', stop: 'Stop the running task', help: 'How this bot works' },
42 sec: 's',
43 min: 'min',
44 h: 'h',
45 },
46 ru: {
47 done: 'Готово',
48 stopped: 'Остановлено',
49 failed: 'Завершилось с ошибкой',
50 refused: 'Отказ',
51 waiting: 'Ждёт вас',
52 noAnswer: '(без текстового ответа)',
53 along: 'По ходу:',
54 result: 'Итог:',
55 queued: '📨 В очереди — Claude возьмётся, когда закончит текущую задачу.',
56 sent: '📨 Отправлено в сессию.',
57 pairHello: (code: string) =>
58 `👋 Чтобы привязать этот чат, введите в Claude Code:\n<code>/pager pair ${code}</code>\n\nКод действует 10 минут.`,
59 paired: '✅ Привязано. Пишите следующий запрос или /help.',
60 idle: '💤 Свободен — ждёт запроса.',
61 idleBackground: (n: number) => `💤 Ход закончен · фоновых задач: ${n} — Claude продолжит, когда ${n === 1 ? 'она завершится' : 'они завершатся'}.`,
62 backgroundNote: (n: number) => `\n\n<i>+ фоновых задач: ${n}</i>`,
63 afterTask: 'после фоновой задачи',
64 working: (s: string, task: string) => `⏳ Работает ${s} над:\n<i>${task}</i>`,
65 nothingToStop: 'Сейчас ничего не выполняется.',
66 stoppedTurn: '⏹ Текущая задача остановлена.',
67 noLast: 'В этой сессии ещё не было ответа.',
68 promptsOff: 'Запросы из Telegram в этой сессии выключены (/config → pager).',
69 help:
70 '<b>Pager</b> — этот чат привязан к сессии Claude Code.\n\n' +
71 'Любой текст — следующий запрос в очередь\n' +
72 '/status — чем занят Claude\n' +
73 '/last — последний ответ целиком\n' +
74 '/stop — остановить текущую задачу\n' +
75 '/help — это сообщение',
76 unknown: (c: string) => `Неизвестная команда /${c} — /help`,
77 commands: { status: 'Чем занят Claude', last: 'Последний ответ целиком', stop: 'Остановить текущую задачу', help: 'Как пользоваться ботом' },
78 sec: 'с',
79 min: 'мин',
80 h: 'ч',
81 },
82} as const
83
84export const words = (lang: Lang) => WORDS[lang] ?? WORDS.en
85
86/** Telegram's HTML mode needs only these three escaped. */
87export const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
88
89/** Telegram's limit for one message, after entities are parsed. */
90export const TG_LIMIT = 4096
91
92/**
93 * Claude answers in Markdown; Telegram's HTML mode shows only a handful of tags. Fenced code →
94 * <pre>, `code` → <code>, **bold** / # headings → <b>, *italic* / _italic_ → <i>, ~~x~~ → <s>,
95 * [text](url) → <a>. Everything else is escaped text, so the result always parses.
96 */
97export function markdownToHtml(md: string): string {
98 const out: string[] = []
99 // Split off fenced blocks first: nothing inside them is Markdown.
100 const fence = /^```[^\n]*\n([\s\S]*?)^```[ \t]*$/gm
101 let last = 0
102 for (let m = fence.exec(md); m !== null; m = fence.exec(md)) {
103 out.push(inlineMarkdown(md.slice(last, m.index)))
104 out.push(`<pre>${escapeHtml((m[1] ?? '').replace(/\n$/, ''))}</pre>`)
105 last = m.index + m[0].length
106 }
107 out.push(inlineMarkdown(md.slice(last)))
108 return out.join('')
109}
110
111function inlineMarkdown(text: string): string {
112 // Inline code is cut out before escaping and emphasis, then put back.
113 const codes: string[] = []
114 const held = text.replace(/`([^`\n]+)`/g, (_, c: string) => `\u0000${codes.push(c) - 1}\u0000`)
115 const lines = escapeHtml(held)
116 .split('\n')
117 .map((line) => {
118 const heading = /^#{1,6}\s+(.+?)\s*#*$/.exec(line)
119 if (heading) return `<b>${heading[1]}</b>`
120 return line.replace(/^(\s*)[*-]\s+/, '$1• ')
121 })
122 .join('\n')
123 return lines
124 .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, t: string, u: string) => `<a href="${u.replace(/"/g, '"')}">${t}</a>`)
125 .replace(/\*\*(?=\S)([^*\n]+?)\*\*/g, '<b>$1</b>')
126 .replace(/__(?=\S)([^_\n]+?)__/g, '<b>$1</b>')
127 .replace(/~~(?=\S)([^~\n]+?)~~/g, '<s>$1</s>')
128 .replace(/(^|[^\w*])\*(?=\S)([^*\n]+?)\*(?![\w*])/g, '$1<i>$2</i>')
129 .replace(/(^|[^\w])_(?=\S)([^_\n]+?)_(?!\w)/g, '$1<i>$2</i>')
130 .replace(/\u0000(\d+)\u0000/g, (_, i: string) => `<code>${escapeHtml(codes[Number(i)] ?? '')}</code>`)
131}
132
133/** The same message without tags: the fallback when Telegram refuses the HTML. */
134export const htmlToPlain = (html: string) =>
135 html
136 .replace(/<[^>]+>/g, '')
137 .replace(/</g, '<')
138 .replace(/>/g, '>')
139 .replace(/"/g, '"')
140 .replace(/&/g, '&')
141
142/** 42 s · 3 min 05 s · 1 h 12 min */
143export function formatDuration(ms: number, lang: Lang): string {
144 const w = words(lang)
145 const total = Math.max(0, Math.round(ms / 1000))
146 if (total < 60) return `${total} ${w.sec}`
147 const minutes = Math.floor(total / 60)
148 if (minutes < 60) return `${minutes} ${w.min} ${String(total % 60).padStart(2, '0')} ${w.sec}`
149 return `${Math.floor(minutes / 60)} ${w.h} ${minutes % 60} ${w.min}`
150}
151
152/** Cuts at a line or word boundary near `max`, so a message never ends mid-word. */
153export function shorten(text: string, max: number): { text: string; isCut: boolean } {
154 if (text.length <= max) return { text, isCut: false }
155 const head = text.slice(0, max)
156 const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '))
157 return { text: (at > max * 0.6 ? head.slice(0, at) : head).trimEnd(), isCut: true }
158}
159
160export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
161
162/**
163 * Splits Markdown into parts of at most `max` characters at line breaks, so every part can go
164 * as its own message. A code fence open at a cut is closed there and reopened in the next part.
165 */
166export function splitMarkdown(md: string, max: number): string[] {
167 const parts: string[] = []
168 let rest = md.trim()
169 let reopen = ''
170 while (rest.length > 0) {
171 let { text } = shorten(rest, max)
172 rest = rest.slice(text.length).replace(/^\s*\n/, '')
173 text = reopen + text
174 const fences = text.match(/^```.*$/gm) ?? []
175 if (fences.length % 2 === 1 && rest.length > 0) {
176 text += '\n```'
177 reopen = `${fences[fences.length - 1]}\n`
178 } else reopen = ''
179 parts.push(text)
180 }
181 return parts.length > 0 ? parts : ['']
182}
183
184/**
185 * The "task is done" messages: folder, outcome, duration, then — when Claude wrote along the
186 * way, between tool calls — those notes, and the final answer. Nothing is cut: a long answer
187 * goes as several messages, "(2/3)" on the ones after the first.
188 *
189 * A turn a background task's notification started says so instead of a duration: its length is
190 * that of the short follow-up, not of the task, and read as the task's it misleads.
191 */
192export function doneMessages(p: {
193 project: string
194 reason: TurnEnd
195 durationMs: number
196 answer: string
197 lang: Lang
198 notes?: readonly string[]
199 afterTask?: boolean
200 background?: number
201}): string[] {
202 const w = words(p.lang)
203 const icon = { answer: '✅', aborted: '⏹', refusal: '🚫', error: '⚠️' }[p.reason]
204 const title = { answer: w.done, aborted: w.stopped, refusal: w.refused, error: w.failed }[p.reason]
205 const when = p.afterTask ? w.afterTask : formatDuration(p.durationMs, p.lang)
206 const header = `${icon} <b>${title}</b> · 📁 ${escapeHtml(p.project)} · ${when}\n\n`
207 const footer = p.background ? w.backgroundNote(p.background) : ''
208 const answer = p.answer.trim() === '' ? w.noAnswer : p.answer.trim()
209 const notes = (p.notes ?? []).map((n) => n.trim()).filter((n) => n !== '' && n !== p.answer.trim())
210 const along = notes.map((n) => `- ${n.replace(/\n+/g, '\n ')}`).join('\n')
211 const body = notes.length === 0 ? answer : `**${w.along}**\n${along}\n\n**${w.result}**\n${answer}`
212 const parts = splitMarkdown(body, MAX_ANSWER)
213 return parts.map((part, i) => {
214 const top = i === 0 ? header : `<i>(${i + 1}/${parts.length})</i>\n\n`
215 const end = i === parts.length - 1 ? footer : ''
216 const rich = top + markdownToHtml(part) + end
217 // Tags count toward nothing, but a part heavy with them can still outgrow the limit.
218 return htmlToPlain(rich).length <= TG_LIMIT ? rich : top + escapeHtml(part) + end
219 })
220}
221
222/** "Claude waits for you": permission prompts and the like, as the engine words them. */
223export function waitingMessage(p: { project: string; message: string; lang: Lang }): string {
224 return `⏸ <b>${words(p.lang).waiting}</b> · 📁 ${escapeHtml(p.project)}\n\n${escapeHtml(p.message)}`
225}
226
227/** The answer to /status: the running turn, or idle — with the background tasks still at work. */
228export function statusMessage(p: { runningSinceMs?: number; nowMs: number; task?: string; background?: number; lang: Lang }): string {
229 const w = words(p.lang)
230 const background = p.background ?? 0
231 if (p.runningSinceMs === undefined) return background > 0 ? w.idleBackground(background) : w.idle
232 const task = shorten(p.task ?? '', 300).text
233 return w.working(formatDuration(p.nowMs - p.runningSinceMs, p.lang), escapeHtml(task)) + (background > 0 ? w.backgroundNote(background) : '')
234}
235
236/** The parts of a Telegram update Pager reads (getUpdates → result[i]). */
237export type TgUpdate = {
238 update_id: number
239 message?: {
240 text?: string
241 chat: { id: number; type?: string }
242 from?: { id: number; first_name?: string; username?: string }
243 }
244}
245
246export type Incoming =
247 | { kind: 'ignore' }
248 | { kind: 'pair-request'; chatId: number; name: string }
249 | { kind: 'command'; chatId: number; name: string; args: string }
250 | { kind: 'prompt'; chatId: number; text: string }
251
252/**
253 * What an update asks for. Only private chats count. Before pairing, every message is a
254 * pair request (the bot answers with a code); after it, everything from another chat is
255 * dropped silently — that chat is the only one allowed to type into the session.
256 */
257export function classify(update: TgUpdate, pairedChatId: number | undefined): Incoming {
258 const m = update.message
259 if (!m || typeof m.text !== 'string' || m.chat.type !== undefined && m.chat.type !== 'private') return { kind: 'ignore' }
260 const chatId = m.chat.id
261 if (pairedChatId === undefined) {
262 return { kind: 'pair-request', chatId, name: m.from?.username ?? m.from?.first_name ?? String(chatId) }
263 }
264 if (chatId !== pairedChatId) return { kind: 'ignore' }
265 const text = m.text.trim()
266 if (text === '') return { kind: 'ignore' }
267 const command = /^\/([a-z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/i.exec(text)
268 if (command) return { kind: 'command', chatId, name: (command[1] ?? '').toLowerCase(), args: (command[2] ?? '').trim() }
269 return { kind: 'prompt', chatId, text }
270}
271
272/** A 6-digit pairing code from any random number in [0, 1). */
273export const pairingCode = (random: number) => String(Math.floor(random * 1_000_000)).padStart(6, '0')
274
275export const PAIRING_TTL_MS = 10 * 60_000
276
277/**
278 * Only one session may long-poll a bot (Telegram answers 409 to a second one), so the
279 * sessions share a lease in the store: whoever holds a fresh one polls, the others only
280 * send. A lease not renewed for LEASE_MS is free to take.
281 */
282export type Lease = { sessionId: string; until: number }
283export const LEASE_MS = 60_000
284
285export function mayPoll(lease: Lease | undefined, sessionId: string, now: number): boolean {
286 return lease === undefined || lease.sessionId === sessionId || lease.until < now
287}
288
289/** "1234567890:AA..." — a quick sanity check before the first request. */
290export const looksLikeToken = (t: string) => /^\d{5,}:[\w-]{20,}$/.test(t.trim())
291
292/** The project's folder name for headers: the last segment of the session's cwd. */
293export const projectName = (cwd: string) => cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
294