Mirrors every AskUserQuestion dialog to Telegram through a local bridge; the first answer, terminal or Telegram, wins

Mirrors every AskUserQuestion dialog of every Claude Code session on the machine to a Telegram chat. The terminal dialog opens as usual; the same question arrives in Telegram with one button per option. The first answer wins: a Telegram answer closes the terminal dialog, and a terminal answer marks the Telegram message as answered there.
Spec: docs/code-plan/specs/2026-10-23-telegram-ask-design.md (local, gitignored). Plan: issue #164.
hooks/register.ts: the mod. A tool.call hook on AskUserQuestion runs next(e) (the dialog) and, beside it, posts the question to the bridge and long-polls for the answer. Returning { result } while next(e) is pending aborts the dialog.bridge/: the daemon (Bun). One bridge serves every session on the machine and polls its bot with getUpdates. API over a Unix socket: POST /ask, GET /wait/:id (25 s long-poll), POST /resolve/:id, POST /update (updates forwarded by another poller, see below).deploy/telegram-ask-bridge.service: the systemd user unit. deploy/forwarded.conf.example: a drop-in for the forwarded setup.📨 Kirim at the bottom. Free text: tap ✏️ Lainnya, then send the answer as a normal message.━━ 2/3 · Header ━━, a tap does nothing), and one 📨 Kirim semua jawaban at the very bottom answers every multi-select question at once; a Send with a question still open names it in a toast.TELEGRAM_CHAT_ID count; any other chat is ignored.parse_mode: HTML, bridge/markdown.ts): bold, italic, code, links, headings, lists and quotes; a table becomes an aligned monospace block because Telegram cannot draw tables. Button labels drop the markers. If Telegram still refuses the HTML, the same message is sent again as plain text./newbot, and keep the token it gives you. curl -s "https://api.telegram.org/bot$TOKEN/getUpdates" | jq '.result[].message.chat.id'
$XDG_CONFIG_HOME/vivera-telegram-ask/.env, by default ~/.config/vivera-telegram-ask/.env; TELEGRAM_ASK_ENV overrides the path): mkdir -p ~/.config/vivera-telegram-ask
install -m 600 /dev/null ~/.config/vivera-telegram-ask/.env
printf 'TELEGRAM_BOT_TOKEN=%s\nTELEGRAM_CHAT_ID=%s\n' "$TOKEN" "$CHAT_ID" > ~/.config/vivera-telegram-ask/.env
cp ~/vivera/mods/telegram-ask/deploy/telegram-ask-bridge.service ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now telegram-ask-bridge.service
claude plugin marketplace add ~/vivera/mods # once, shared with sprite-model-router
claude plugin install telegram-ask@vivera-mods
Then /reload-plugins in a running session.
~/vivera or Bun is not at ~/.bun/bin/bun (check with command -v bun), point the unit at both with a drop-in that clears ExecStart and sets it again (command -v bun fills in the Bun path; replace /path/to/vivera with your clone), then restart: mkdir -p ~/.config/systemd/user/telegram-ask-bridge.service.d
printf '[Service]\nExecStart=\nExecStart=%s %s\n' "$(command -v bun)" /path/to/vivera/mods/telegram-ask/bridge/main.ts \
> ~/.config/systemd/user/telegram-ask-bridge.service.d/paths.conf
systemctl --user daemon-reload && systemctl --user restart telegram-ask-bridge.service
The cp and claude plugin marketplace add lines above take the same repository path.
KEY=value # note drops the note; a # inside quotes or with no space before it stays part of the value.getUpdates while one is set.$XDG_RUNTIME_DIR/vivera-telegram-ask.sock, mode 0600. Override with TELEGRAM_ASK_SOCKET for the bridge and the socketPath option in /config for the mod.OpenCode has no AskUserQuestion hook, so the mirror there is a plugin: it reads the session form the built-in question tool opens and answers that form through the server's own HTTP API. It uses the same bridge, socket, and bot as the Claude Code half.
cp ~/vivera/mods/telegram-ask/opencode/telegram-ask.ts ~/.config/opencode/plugins/telegram-ask.ts
Restart OpenCode afterwards.
opencode serve --service session the dialog opens in the TUI and the same question arrives in Telegram; an answer entered in Telegram closes the dialog and reaches the model, a TUI answer marks the Telegram message, and Esc cancels it.TELEGRAM_ASK_DEBUG=<file> in the environment that starts OpenCode logs wait and reply status lines, never question text, answers, or the password.execute through ctx.tool.transform: measured the same day, even a pass-through wrapper hides the TUI dialog. bun test scripts/telegram-ask.test.mjs fails when the plugin's code names ctx.tool; aliasing it through another variable would slip past that check.rm ~/.config/opencode/plugins/telegram-ask.ts, then restart OpenCode.Telegram answers 409 to a second getUpdates poller on one token. If another program already polls the bot you want to use, that program stays the only poller, the bridge stops polling, and the program forwards each update to the bridge's POST /update. The bridge answers { consumed } within about a second: a tap on a telegram-ask button is always consumed, a text message only while a question waits for its ✏️ Lainnya answer, and nothing from another chat. The forwarding program keeps every update that is not consumed.
Snipset desktop does this when its telegram_bridge_socket preference names the bridge socket ($XDG_RUNTIME_DIR/vivera-telegram-ask.sock unless overridden; Snipset PR belajarcarabelajar/Snipset#730).
mkdir -p ~/.config/systemd/user/telegram-ask-bridge.service.d
cp ~/vivera/mods/telegram-ask/deploy/forwarded.conf.example ~/.config/systemd/user/telegram-ask-bridge.service.d/forwarded.conf
# edit forwarded.conf: uncomment TELEGRAM_ASK_ENV and point it at the env file holding that bot's token and your chat id
systemctl --user daemon-reload && systemctl --user restart telegram-ask-bridge.service
The drop-in sets TELEGRAM_ASK_POLL=0; the bridge then logs polling off (updates are forwarded to POST /update) and only serves the socket.
journalctl --user -u telegram-ask-bridge -f: ask ids and counts only, never question text, answers, or the token.telegram-ask: bridge offline and works as before. A pending ask is lost when the bridge restarts; its buttons then answer "Pertanyaan ini sudah selesai".✏️ Lainnya tap keeps taking your next text as long as its question is open. An ask is dropped about 2 minutes after its session stops polling the bridge (terminal closed or killed); its buttons then answer "Pertanyaan ini sudah selesai".telegram-ask: not sent to Telegram (<status>) under the dialog: the bridge is up but refused the question; the terminal dialog works as usual.another bridge is already listening on <path> (the unit does not restart on it). A socket file nobody answers on is replaced. The log line names the missing file or key, never a value.getUpdates 409 in the log: another program polls the same bot; use the forwarded setup above.bun test ./mods/telegram-ask/bridge/ # bridge, Bun tests named *.spec.ts
claude plugin test mods/telegram-ask # the hook, *.test.ts
bun test scripts/telegram-ask.test.mjs # all of the above plus the OpenCode plugin, as the repository suite runs them
The bridge tests end in .spec.ts because claude plugin test runs every *.test.ts under the mod and refuses a bun:test import. mods/telegram-ask/opencode/ therefore carries no test file: the suite test asserts there that the plugin module loads, exports id and setup, keeps the bridge socket file name and the shared TELEGRAM_ASK_SOCKET override, and never names ctx.tool in code.
systemctl --user disable --now telegram-ask-bridge.service
rm -r ~/.config/systemd/user/telegram-ask-bridge.service.d # only if you installed the drop-in
rm ~/.config/systemd/user/telegram-ask-bridge.service && systemctl --user daemon-reload
claude plugin uninstall telegram-ask@vivera-modshooks/register.ts 129 lines1import type { Register } from 'claude-code'
2
3const SOCKET_NAME = 'vivera-telegram-ask.sock'
4
5// Read once per process: the runtime dir does not change under a running session.
6let runtimeDir: string | undefined
7
8type Answers = Record<string, string>
9type Outcome = { from: 'telegram'; answers: Answers } | { from: 'dialog'; value: unknown } | { from: 'dialogError'; error: unknown }
10
11function basename(path: string): string {
12 return path.replace(/\/+$/, '').split('/').pop() ?? ''
13}
14
15function hasAnswers(value: unknown): value is Answers {
16 return typeof value === 'object' && value !== null && Object.keys(value).length > 0
17}
18
19export const register: Register = (on, options) => {
20 const configured = String(options.socketPath ?? '').trim()
21
22 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
23 if (hasAnswers(e.answers)) return next(e)
24
25 const dialog = next(e)
26 const settled: Promise<Outcome> = dialog.then(
27 value => ({ from: 'dialog', value }),
28 error => ({ from: 'dialogError', error }),
29 )
30
31 let socketPath = configured
32 let id: string
33 try {
34 if (!socketPath) {
35 if (runtimeDir === undefined) {
36 const ran = await $.process.run(['printenv', 'XDG_RUNTIME_DIR'])
37 runtimeDir = ran.exitCode === 0 ? ran.stdout.trim() : ''
38 }
39 if (!runtimeDir) throw new Error('XDG_RUNTIME_DIR is not set')
40 socketPath = `${runtimeDir}/${SOCKET_NAME}`
41 }
42 const root = await $.session.root()
43 const sessionId = await $.session.id()
44 const asked = await $.http.fetch('http://bridge/ask', {
45 method: 'POST',
46 headers: { 'content-type': 'application/json' },
47 body: JSON.stringify({
48 project: basename(root),
49 session: sessionId ? sessionId.slice(0, 8) : basename(await $.session.cwd()),
50 context: await latestClaudeText(),
51 questions: e.questions,
52 }),
53 socketPath,
54 })
55 if (!asked.ok) {
56 // The bridge is up but refused this question; "offline" would send the owner to the wrong fix.
57 $.ui.notice(e.tool_use_id, `telegram-ask: not sent to Telegram (${asked.status})`)
58 return dialog
59 }
60 id = String(JSON.parse(asked.text).id)
61 } catch {
62 $.ui.notice(e.tool_use_id, 'telegram-ask: bridge offline')
63 return dialog
64 }
65 $.ui.notice(e.tool_use_id, 'telegram-ask: juga dikirim ke Telegram')
66
67 // Set once the race settles, so a /resolve the bridge never got cannot leave a poll looping.
68 let raceOver = false
69 const fromTelegram = waitForTelegram(id, socketPath)
70 const first = await Promise.race([
71 settled,
72 fromTelegram.then(answers => (answers ? ({ from: 'telegram', answers } as Outcome) : settled)),
73 ])
74 raceOver = true
75
76 if (first.from === 'telegram') return { result: { questions: e.questions, answers: first.answers } }
77
78 const result = first.from === 'dialog' ? (first.value as { result?: { answers?: unknown } }) : undefined
79 const answers = result?.result?.answers
80 await resolve(id, socketPath, hasAnswers(answers) ? { by: 'terminal', answers } : { by: 'cancelled' })
81 return dialog
82
83 async function latestClaudeText(): Promise<string> {
84 try {
85 const messages = await $.session.messages({ as: 'api' })
86 const last = [...messages].reverse().find(m => m.role === 'assistant')
87 if (!last) return ''
88 return last.content
89 .flatMap(block => (block.type === 'text' ? [block.text] : []))
90 .join('\n')
91 } catch {
92 return ''
93 }
94 }
95
96 // Polls until the bridge has answers; null when it closed, forgot the id, or failed three times in a row.
97 async function waitForTelegram(askId: string, sock: string): Promise<Answers | null> {
98 let failures = 0
99 while (!raceOver) {
100 try {
101 const res = await $.http.fetch(`http://bridge/wait/${askId}`, { socketPath: sock })
102 failures = 0
103 if (!res.ok) return null
104 const body = JSON.parse(res.text) as { status?: string; answers?: Answers }
105 if (body.status === 'answered' && body.answers) return body.answers
106 if (body.status !== 'pending') return null
107 } catch {
108 // One dropped long-poll must not end listening to Telegram for the rest of the dialog.
109 if (++failures >= 3) return null
110 }
111 }
112 return null
113 }
114
115 async function resolve(askId: string, sock: string, body: object): Promise<void> {
116 try {
117 await $.http.fetch(`http://bridge/resolve/${askId}`, {
118 method: 'POST',
119 headers: { 'content-type': 'application/json' },
120 body: JSON.stringify(body),
121 socketPath: sock,
122 })
123 } catch {
124 // The terminal answer stands whether or not the bridge heard about it.
125 }
126 }
127 }).catch(($, e, next) => next(e))
128}
129