polygram 0.12.0-rc.29 → 0.12.0-rc.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/db.js CHANGED
@@ -19,7 +19,7 @@ const Database = require('better-sqlite3');
19
19
  // SCHEMA_VERSION; the early-return on line ~42 then skipped the
20
20
  // migration loop on any DB already at user_version=8 → turn_metrics
21
21
  // table never created → INSERT prepare at startup crashed polygram.
22
- const SCHEMA_VERSION = 11;
22
+ const SCHEMA_VERSION = 12;
23
23
 
24
24
  // Sentinel `error` value for outbound rows whose API call may or may not
25
25
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Interactive-question handlers (0.12 ask feature) — the integration glue between
3
+ * the pure state machine (lib/questions/questions.js), the store
4
+ * (lib/questions/store.js), Telegram, and the bridge answer write-back.
5
+ *
6
+ * renderAsk — claude called `ask`: issue a row, send the keyboard.
7
+ * handleQuestionCallback — a `q:` button tap: validate, mutate, advance/resolve.
8
+ * tryConsumeAsAnswer — dispatcher hook: a typed message while a question is
9
+ * open (free-text "Other", or a nudge for the wrong user).
10
+ * expireQuestion — timeout sweep / cancel: answer {timedout}/{cancelled}, strip.
11
+ *
12
+ * Security (review): per-row 128-bit token in callback_data + claim-on-first-tap
13
+ * respondent authz + plain-text body (no parse_mode) for agent content.
14
+ *
15
+ * Anti-hang invariant (review-hardened): claude's `ask` tool call must be answered
16
+ * EXACTLY once and never hang. Therefore every terminal path hands the result to
17
+ * claude *first* (guarded) and only then marks the row terminal — `finalize()`. If
18
+ * the write-back THROWS, the row is LEFT pending so the timeout sweep can recover
19
+ * with {timedout} (never resolved-but-hung). If the write-back is an undelivered
20
+ * NO-OP (returns false — session gone / no live bridge), it is surfaced loudly and
21
+ * the row is still resolved (a dead session can't be delivered; the bridge's own
22
+ * 20-min ceiling backstops the rare live-proc case). A failed Telegram send is also
23
+ * hang-safe: we answer {cancelled} rather than leaving a pending row with no
24
+ * on-screen keyboard. renderAsk that throws BEFORE issuing a row answers {cancelled}
25
+ * itself (no row exists for the sweep to find); tryConsumeAsAnswer never throws out
26
+ * of the dispatcher (a store error degrades to "not an answer", never a dropped msg).
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const Q = require('../questions/questions');
32
+ const { tokensEqual } = require('../questions/store');
33
+
34
+ function createQuestionHandlers({
35
+ questions, // store (lib/questions/store.js)
36
+ tg,
37
+ bot,
38
+ botName,
39
+ logEvent = () => {},
40
+ answerQuestion, // (sessionKey, toolCallId, result) → write question_answer to the bridge
41
+ logger = console,
42
+ } = {}) {
43
+
44
+ function strip(chatId, msgId, threadId, text) {
45
+ if (msgId == null) return Promise.resolve();
46
+ return tg(bot, 'editMessageText', {
47
+ chat_id: chatId, message_id: msgId, text,
48
+ ...(threadId && { message_thread_id: threadId }),
49
+ }, { source: 'question-edit', botName })
50
+ .catch((e) => logger.error?.(`[${botName}] question strip failed: ${e.message}`));
51
+ }
52
+
53
+ async function sendCurrent(row, state) {
54
+ const view = Q.renderCurrent(state, `q:${row.id}:${row.callback_token}`);
55
+ if (!view) return null;
56
+ // PLAIN-TEXT (no parse_mode): option labels/descriptions are agent-authored.
57
+ const sent = await tg(bot, 'sendMessage', {
58
+ chat_id: row.chat_id, text: view.text, reply_markup: view.reply_markup,
59
+ ...(row.thread_id && { message_thread_id: row.thread_id }),
60
+ }, { source: 'question', botName }).catch((e) => {
61
+ logger.error?.(`[${botName}] question send failed: ${e.message}`);
62
+ return null;
63
+ });
64
+ return sent?.message_id ?? null;
65
+ }
66
+
67
+ /**
68
+ * Hand the result to claude FIRST (guarded), then mark the row terminal. On
69
+ * write-back failure: leave the row pending (the timeout sweep recovers it) and
70
+ * return false — NEVER resolved-but-hung. Returns true when claude was answered.
71
+ */
72
+ function finalize(row, result, status = 'answered') {
73
+ let delivered;
74
+ try {
75
+ delivered = answerQuestion?.(row.session_key, row.tool_call_id, result);
76
+ } catch (e) {
77
+ logger.error?.(`[${botName}] answerQuestion failed for ${row.tool_call_id}: ${e.message}`);
78
+ return false; // threw → leave the row pending; the timeout sweep recovers it
79
+ }
80
+ // A *false* return is a silent no-op (session gone / no live bridge), NOT a
81
+ // throw. Surface it loudly and still resolve: a dead session can never be
82
+ // delivered, so leaving it pending would have the 30s sweep re-strip +
83
+ // re-answer it forever. The rare live-proc-but-unwritable-bridge case is
84
+ // backstopped by the bridge's own 20-min answer ceiling.
85
+ if (delivered === false) {
86
+ logger.error?.(`[${botName}] answerQuestion undelivered (session gone / no bridge) for ${row.tool_call_id}`);
87
+ logEvent('question-answer-undelivered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
88
+ }
89
+ questions.resolve(row.id, status);
90
+ return true;
91
+ }
92
+
93
+ // ── claude called ask → render the first question ──────────────────
94
+ async function renderAsk({ sessionKey, chatId, threadId = null, turnId = null, toolCallId, questions: qs }) {
95
+ let row = null;
96
+ try {
97
+ // Idempotency: a bridge retry of the same tool_call_id must not double-render.
98
+ if (questions.getByToolCallId(toolCallId)) return null;
99
+
100
+ if (!Array.isArray(qs) || qs.length === 0) {
101
+ try { answerQuestion?.(sessionKey, toolCallId, { answers: [] }); } catch (e) {
102
+ logger.error?.(`[${botName}] answerQuestion(empty) failed: ${e.message}`);
103
+ }
104
+ return null;
105
+ }
106
+ // One open question per session: cancel + unblock any prior open ask first.
107
+ const prior = questions.getOpenForSession(sessionKey);
108
+ if (prior) {
109
+ finalize(prior, { cancelled: true }, 'cancelled');
110
+ const pIds = JSON.parse(prior.message_ids_json || '[]');
111
+ if (pIds[0]) strip(prior.chat_id, pIds[0], prior.thread_id, 'This question was replaced.');
112
+ }
113
+ const state = Q.initState(qs);
114
+ row = questions.issue({
115
+ bot_name: botName, session_key: sessionKey, chat_id: chatId, thread_id: threadId,
116
+ turn_id: turnId, tool_call_id: toolCallId, questions: qs, state,
117
+ });
118
+ const msgId = await sendCurrent(row, state);
119
+ if (msgId == null) {
120
+ // Couldn't deliver the keyboard — don't strand claude on a pending row.
121
+ finalize(row, { cancelled: true, error: 'failed to deliver the question' }, 'cancelled');
122
+ logEvent('question-send-failed', { session_key: sessionKey, tool_call_id: toolCallId, phase: 'first' });
123
+ return null;
124
+ }
125
+ questions.setMessageIds(row.id, [msgId]);
126
+ logEvent('question-asked', { session_key: sessionKey, chat_id: chatId, tool_call_id: toolCallId, count: qs.length });
127
+ return row;
128
+ } catch (e) {
129
+ logger.error?.(`[${botName}] renderAsk failed for ${toolCallId}: ${e.message}`);
130
+ // Anti-hang: a throw BEFORE the row was issued (store error, etc.) leaves
131
+ // claude blocked with no row for the sweep to recover → answer {cancelled}
132
+ // now. If the row WAS issued (throw in a later step), it is pending and the
133
+ // timeout sweep will recover it with {timedout}.
134
+ if (!row) {
135
+ try { answerQuestion?.(sessionKey, toolCallId, { cancelled: true, error: 'failed to render question' }); } catch (e2) {
136
+ logger.error?.(`[${botName}] renderAsk fallback answer failed for ${toolCallId}: ${e2.message}`);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ }
142
+
143
+ // ── a `q:<id>:<token>:<action>` button tap ─────────────────────────
144
+ async function handleQuestionCallback(ctx) {
145
+ const data = ctx.callbackQuery?.data || '';
146
+ const m = String(data).match(/^q:(\d+):([^:]+):(.+)$/);
147
+ if (!m) return;
148
+ const id = parseInt(m[1], 10);
149
+ const token = m[2];
150
+ const actionStr = m[3];
151
+
152
+ const row = questions.getById(id);
153
+ if (!row) { await ack(ctx, 'This question expired.', true); return; }
154
+ if (!tokensEqual(row.callback_token, token)) {
155
+ logEvent('question-token-mismatch', { id, from_user: ctx.from?.id });
156
+ await ack(ctx, 'Bad token.', true); return;
157
+ }
158
+ if (row.status !== 'pending') { await ack(ctx, `Already ${row.status}.`, true); return; }
159
+
160
+ // Respondent authorization: first tapper claims the question; others rejected.
161
+ const auth = questions.claimOrCheck(id, ctx.from?.id);
162
+ if (!auth.ok) {
163
+ logEvent('question-foreign-responder', { id, from_user: ctx.from?.id, owner: row.from_id });
164
+ await ack(ctx, 'This question is for someone else.', true); return;
165
+ }
166
+
167
+ const state = JSON.parse(row.state_json);
168
+ const res = Q.applyTap(state, Q.parseAction(actionStr));
169
+ const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
170
+
171
+ if (res.kind === 'reject') { await ack(ctx, res.message, true); return; }
172
+
173
+ if (res.kind === 'toggled') {
174
+ questions.updateState(id, res.state, false);
175
+ const view = Q.renderCurrent(res.state, `q:${id}:${token}`);
176
+ await tg(bot, 'editMessageReplyMarkup', {
177
+ chat_id: row.chat_id, message_id: msgId, reply_markup: view.reply_markup,
178
+ ...(row.thread_id && { message_thread_id: row.thread_id }),
179
+ }, { source: 'question-edit', botName })
180
+ .catch((e) => logger.error?.(`[${botName}] toggle re-render failed (q ${id}): ${e.message}`));
181
+ await ack(ctx);
182
+ return;
183
+ }
184
+
185
+ if (res.kind === 'awaiting-other') {
186
+ questions.updateState(id, res.state, true);
187
+ await strip(row.chat_id, msgId, row.thread_id, 'Send your answer as a message.');
188
+ await ack(ctx, 'Type your answer ↓');
189
+ return;
190
+ }
191
+
192
+ // advanced — record + receipt, then next question or finish.
193
+ await advance(ctx, row, res, false);
194
+ }
195
+
196
+ // ── a typed message while a question is open (Other / nudge) ────────
197
+ async function tryConsumeAsAnswer({ sessionKey, fromId, text }) {
198
+ try {
199
+ const row = questions.getOpenForSession(sessionKey);
200
+ if (!row) return { consumed: false };
201
+ // Only an in-progress free-text capture diverts typed messages. A question
202
+ // awaiting a BUTTON tap does not swallow ordinary chatter (review: do not eat
203
+ // every group member's message for the whole question lifetime).
204
+ if (!row.awaiting_other) return { consumed: false };
205
+ // /stop, /new and other commands are never consumed as a free-text answer.
206
+ if (/^\/(stop|new|reset|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
207
+ // Identity: only the claimed owner supplies the free-text answer.
208
+ const auth = questions.claimOrCheck(row.id, fromId);
209
+ if (!auth.ok) {
210
+ tg(bot, 'sendMessage', { chat_id: row.chat_id, text: 'Please answer the open question above first.',
211
+ ...(row.thread_id && { message_thread_id: row.thread_id }) }, { source: 'question-nudge', botName })
212
+ .catch((e) => logger.error?.(`[${botName}] question nudge failed: ${e.message}`));
213
+ return { consumed: true };
214
+ }
215
+ const state = JSON.parse(row.state_json);
216
+ const res = Q.applyFreeText(state, text);
217
+ if (res.kind !== 'advanced') return { consumed: false };
218
+ await advance({ from: { id: fromId } }, row, res, true);
219
+ return { consumed: true };
220
+ } catch (e) {
221
+ // Never throw out of the message dispatcher: a store/parse error here must
222
+ // degrade to "not an answer" so the user's message still reaches normal
223
+ // dispatch instead of being silently dropped.
224
+ logger.error?.(`[${botName}] tryConsumeAsAnswer failed: ${e.message}`);
225
+ return { consumed: false };
226
+ }
227
+ }
228
+
229
+ // Does `fromId` own an open free-text ("Other") capture for this session? The
230
+ // dispatcher uses this to let the owner's typed answer bypass a group's mention
231
+ // gate — only the claimed owner, never a bystander.
232
+ function isAwaitingOtherFrom(sessionKey, fromId) {
233
+ if (fromId == null) return false;
234
+ try {
235
+ const row = questions.getOpenForSession(sessionKey);
236
+ return !!(row && row.awaiting_other && row.from_id != null && Number(row.from_id) === Number(fromId));
237
+ } catch { return false; }
238
+ }
239
+
240
+ // ── timeout sweep / external cancel ────────────────────────────────
241
+ async function expireQuestion(row, { status = 'timeout', message = 'This question timed out.' } = {}) {
242
+ const ids = JSON.parse(row.message_ids_json || '[]');
243
+ if (ids[0]) await strip(row.chat_id, ids[0], row.thread_id, message);
244
+ const result = status === 'cancelled' ? { cancelled: true } : { timedout: true };
245
+ finalize(row, result, status);
246
+ logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
247
+ }
248
+
249
+ // shared: record an advanced result, post a receipt, then next-Q or resolve.
250
+ async function advance(ctx, row, res, fromText) {
251
+ const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
252
+ if (!fromText) {
253
+ await strip(row.chat_id, msgId, row.thread_id, `✓ ${res.receipt}`);
254
+ await ack(ctx, 'Recorded');
255
+ }
256
+
257
+ if (res.done) {
258
+ questions.updateState(row.id, res.state, false);
259
+ // Answer claude FIRST (guarded), THEN mark answered. If the write-back
260
+ // throws, leave the row pending → the timeout sweep recovers it.
261
+ if (!finalize(row, Q.assemble(res.state), 'answered')) {
262
+ logEvent('question-answer-writeback-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id });
263
+ return;
264
+ }
265
+ logEvent('question-answered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
266
+ return;
267
+ }
268
+ // next question → new message; on send failure, don't strand claude.
269
+ const nextMsgId = await sendCurrent(row, res.state);
270
+ if (nextMsgId == null) {
271
+ finalize(row, { cancelled: true, error: 'failed to deliver the next question' }, 'cancelled');
272
+ logEvent('question-send-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id, phase: 'next', q_index: res.state.qIndex });
273
+ return;
274
+ }
275
+ questions.updateState(row.id, res.state, false);
276
+ questions.setMessageIds(row.id, [nextMsgId]);
277
+ }
278
+
279
+ function ack(ctx, text, alert = false) {
280
+ if (!ctx || typeof ctx.answerCallbackQuery !== 'function') return Promise.resolve();
281
+ return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
282
+ }
283
+
284
+ return { renderAsk, handleQuestionCallback, tryConsumeAsAnswer, expireQuestion, isAwaitingOtherFrom };
285
+ }
286
+
287
+ module.exports = { createQuestionHandlers };
@@ -50,7 +50,12 @@ const ToolCallMessageSchema = z.object({
50
50
  kind: z.literal('tool'),
51
51
  session: NonEmptyString,
52
52
  tool_call_id: ToolCallId,
53
- name: z.enum(['reply', 'react', 'edit_message']),
53
+ // 'ask' (0.12 interactive questions): a blocking tool whose answer rides back
54
+ // on a `question_answer` daemon→bridge message (NOT the fast `tool_ack`); its
55
+ // args are {chat_id, turn_id?, questions:[...]}, not reply-shaped. _dispatchToolCall
56
+ // branches on the name so the reply-only paths (chat_id-mismatch, content-dedup,
57
+ // reply-turn-binding) don't run for it.
58
+ name: z.enum(['reply', 'react', 'edit_message', 'ask']),
54
59
  args: z.object({}).passthrough(),
55
60
  }).passthrough();
56
61
 
@@ -118,6 +123,16 @@ const ToolAckMessageSchema = z.object({
118
123
  error: z.string().optional(),
119
124
  }).passthrough();
120
125
 
126
+ // 0.12 interactive questions: carries the user's answer back for an `ask` tool
127
+ // call. Separate from `tool_ack` (which has no payload field and resolves the
128
+ // fast reply round-trip) so a blocking question can return a structured result.
129
+ // `result` is one of {answers:[...]} | {cancelled:true} | {timedout:true}.
130
+ const QuestionAnswerMessageSchema = z.object({
131
+ kind: z.literal('question_answer'),
132
+ tool_call_id: ToolCallId,
133
+ result: z.object({}).passthrough(),
134
+ }).passthrough();
135
+
121
136
  const PingMessageSchema = z.object({
122
137
  kind: z.literal('ping'),
123
138
  }).passthrough();
@@ -128,6 +143,7 @@ const AnyDaemonToBridgeMessage = z.discriminatedUnion('kind', [
128
143
  UserMessageSchema,
129
144
  PermVerdictMessageSchema,
130
145
  ToolAckMessageSchema,
146
+ QuestionAnswerMessageSchema,
131
147
  PingMessageSchema,
132
148
  ]);
133
149
 
@@ -170,6 +186,7 @@ module.exports = {
170
186
  UserMessageSchema,
171
187
  PermVerdictMessageSchema,
172
188
  ToolAckMessageSchema,
189
+ QuestionAnswerMessageSchema,
173
190
  PingMessageSchema,
174
191
  AnyDaemonToBridgeMessage,
175
192
  // helpers
@@ -116,6 +116,31 @@ function resolveToolAck(toolCallId, ok, error) {
116
116
  ok ? p.resolve() : p.reject(new Error(error || 'daemon rejected delivery'))
117
117
  }
118
118
 
119
+ // ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
120
+ // Separate from tool_ack: a question can take MINUTES (the daemon-side 8-min
121
+ // question timeout resolves it with {timedout} well before this hard ceiling).
122
+ const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
123
+ const QUESTION_ANSWER_TIMEOUT_MS = 20 * 60 * 1000
124
+
125
+ function awaitQuestionAnswer(toolCallId) {
126
+ return new Promise((resolve) => {
127
+ const timer = setTimeout(() => {
128
+ pendingQuestions.delete(toolCallId)
129
+ resolve({ timedout: true }) // never reject — the agent gets a clean result
130
+ }, QUESTION_ANSWER_TIMEOUT_MS)
131
+ timer.unref?.() // a pending answer must not, by itself, hold the event loop open
132
+ pendingQuestions.set(toolCallId, { resolve, timer })
133
+ })
134
+ }
135
+
136
+ function resolveQuestionAnswer(toolCallId, result) {
137
+ const p = pendingQuestions.get(toolCallId)
138
+ if (!p) return
139
+ pendingQuestions.delete(toolCallId)
140
+ clearTimeout(p.timer)
141
+ p.resolve(result ?? { cancelled: true })
142
+ }
143
+
119
144
  // ─── Socket: connect, handshake, then bidirectional JSON-lines ──
120
145
  const sock = connect(SOCK)
121
146
 
@@ -213,6 +238,10 @@ function handleDaemonMessage(msg) {
213
238
  resolveToolAck(msg.tool_call_id, msg.ok, msg.error)
214
239
  break
215
240
 
241
+ case 'question_answer':
242
+ resolveQuestionAnswer(msg.tool_call_id, msg.result)
243
+ break
244
+
216
245
  case 'ping':
217
246
  // R5: ping is the ONLY signal that proves the daemon's ping-loop is
218
247
  // healthy. Update watchdog timestamp here, not on the generic 'data'
@@ -283,15 +312,70 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
283
312
  },
284
313
  required: ['chat_id', 'text'],
285
314
  },
315
+ }, {
316
+ // 0.12 interactive questions: ask the Telegram user a multiple-choice question
317
+ // as tap-to-answer inline buttons. USE THIS instead of any interactive menu —
318
+ // it returns the user's selection(s) as the tool result. Blocks until answered.
319
+ name: 'ask',
320
+ description: 'Ask the Telegram user a multiple-choice question (rendered as inline ' +
321
+ 'keyboard buttons; supports multiSelect + a free-text "Other"). Use this ' +
322
+ 'for ANY choice/confirmation — never present a numbered list and wait, and ' +
323
+ 'never use a terminal selection menu. Blocks until the user answers; returns ' +
324
+ '{answers:[{header,selected:[label...],other?}]} (or {cancelled}/{timedout}).',
325
+ inputSchema: {
326
+ type: 'object',
327
+ properties: {
328
+ chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
329
+ turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
330
+ questions: {
331
+ type: 'array', description: 'Up to 4 questions, asked one at a time.',
332
+ items: {
333
+ type: 'object',
334
+ properties: {
335
+ header: { type: 'string', description: 'Short chip label (≤12 chars).' },
336
+ question: { type: 'string', description: 'The question text.' },
337
+ multiSelect: { type: 'boolean', description: 'Allow selecting several options (checkboxes).' },
338
+ allowOther: { type: 'boolean', description: 'Offer a free-text "type my own" answer (default true).' },
339
+ options: {
340
+ type: 'array', description: '2–4 options.',
341
+ items: { type: 'object', properties: {
342
+ label: { type: 'string', description: 'Button label (≤40 chars).' },
343
+ description: { type: 'string', description: 'Shown in the message body.' },
344
+ } },
345
+ },
346
+ },
347
+ required: ['question', 'options'],
348
+ },
349
+ },
350
+ },
351
+ required: ['chat_id', 'questions'],
352
+ },
286
353
  }],
287
354
  }
288
355
  })
289
356
 
290
357
  mcp.setRequestHandler(CallToolRequestSchema, async req => {
291
- if (req.params.name !== 'reply') {
358
+ if (req.params.name !== 'reply' && req.params.name !== 'ask') {
292
359
  return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
293
360
  }
294
361
  const toolCallId = randomUUID()
362
+
363
+ // `ask` blocks for the user's answer (question_answer), NOT a fast tool_ack.
364
+ if (req.params.name === 'ask') {
365
+ const answerP = awaitQuestionAnswer(toolCallId)
366
+ try {
367
+ sock.write(JSON.stringify({
368
+ kind: 'tool', session: SESSION_KEY, tool_call_id: toolCallId, name: 'ask', args: req.params.arguments,
369
+ }) + '\n')
370
+ } catch (e) {
371
+ // The daemon never received the ask → no row, no sweep. Resolve the awaiter
372
+ // now (clears the 20-min timer) instead of stranding the agent on it.
373
+ resolveQuestionAnswer(toolCallId, { cancelled: true, error: `bridge write failed: ${e?.message || e}` })
374
+ }
375
+ const result = await answerP
376
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] }
377
+ }
378
+
295
379
  const ackP = awaitToolAck(toolCallId)
296
380
  sock.write(JSON.stringify({
297
381
  kind: 'tool',
@@ -300,6 +300,11 @@ class CliProcess extends Process {
300
300
 
301
301
  // pending turn(s): turn_id → { resolve, reject, replies: [], quietTimer, hardTimer, startedAt }
302
302
  this.pendingTurns = new Map();
303
+ // 0.12 interactive questions: tool_call_ids of `ask` calls awaiting an answer.
304
+ // While non-empty, the keep-alive interval resets the turn's idle ceiling (an
305
+ // idle `ask` fires no tool hooks, so _extendQuietOnToolActivity wouldn't run).
306
+ this._openQuestions = new Set();
307
+ this._questionKeepAliveTimer = null;
303
308
 
304
309
  // File-send outbound cap (bot → user). Safe cloud default; overwritten in
305
310
  // _spawnTmuxClaude with the backend/chat-resolved value before any turn.
@@ -702,9 +707,11 @@ class CliProcess extends Process {
702
707
  'see or navigate — it silently wedges the entire session until it is manually',
703
708
  'cleared. (Rich tap-to-answer choices are coming; until then this is a hard rule.)',
704
709
  '',
705
- 'To ask a multiple-choice question, write the options as a numbered list',
706
- 'inside your `mcp__polygram-bridge__reply` text and ask the user to reply with',
707
- 'the number (or free text). Do the same for yes/no and any selection.',
710
+ 'To ask a multiple-choice question, a confirmation, or yes/no, call the',
711
+ '`mcp__polygram-bridge__ask` tool it renders tap-to-answer inline buttons',
712
+ '(supports multiSelect via `multiSelect:true` and a free-text answer via',
713
+ '`allowOther:true`) and returns the user\'s selection(s) as the tool result.',
714
+ 'Prefer `ask` over a typed numbered list whenever you are offering choices.',
708
715
  '',
709
716
  '### Sending FILES (tracks, images, docs) to the user',
710
717
  '',
@@ -1004,6 +1011,31 @@ class CliProcess extends Process {
1004
1011
  return;
1005
1012
  }
1006
1013
 
1014
+ // 0.12 interactive questions: `ask` is a BLOCKING tool whose answer rides back
1015
+ // on a `question_answer` message (NOT tool_ack). Skip the reply-only paths
1016
+ // (content-dedup, rate-limit, the reply dispatcher) — just guard chat_id and
1017
+ // emit so polygram renders the keyboard; the answer is written later via
1018
+ // writeQuestionAnswer(). claude is now idle waiting on the result, so start a
1019
+ // keep-alive that resets the turn's idle ceiling (no tool hooks fire meanwhile).
1020
+ if (msg.name === 'ask') {
1021
+ if (this.chatId != null && args.chat_id != null && String(args.chat_id) !== String(this.chatId)) {
1022
+ this._writeToBridge({ kind: 'question_answer', tool_call_id: msg.tool_call_id, result: { cancelled: true, error: 'chat_id mismatch' } });
1023
+ return;
1024
+ }
1025
+ this._openQuestions.add(msg.tool_call_id);
1026
+ this._startQuestionKeepAlive();
1027
+ this.emit('question-asked', {
1028
+ sessionKey: this.sessionKey,
1029
+ chatId: this.chatId,
1030
+ threadId: this.threadId,
1031
+ turnId: args.turn_id || null,
1032
+ toolCallId: msg.tool_call_id,
1033
+ questions: Array.isArray(args.questions) ? args.questions : [],
1034
+ backend: this.backend,
1035
+ });
1036
+ return;
1037
+ }
1038
+
1007
1039
  // Review F#16: secondary content-hash dedup catches retries that come in
1008
1040
  // with a NEW tool_call_id (Claude regenerates the id on each retry after
1009
1041
  // an isError ack). Window-based so legit repeat sends eventually pass.
@@ -2174,6 +2206,11 @@ class CliProcess extends Process {
2174
2206
  this.pendingTurns.clear();
2175
2207
  this.pendingQueue.length = 0;
2176
2208
  this.inFlight = false;
2209
+ // 0.12: drop the interactive-question keep-alive here too, for parity with
2210
+ // _doKill — pm reacts to 'bridge-disconnected' by killing us anyway, but don't
2211
+ // depend on that ordering to stop the 60s interval / clear the open set.
2212
+ this._stopQuestionKeepAlive();
2213
+ this._openQuestions.clear();
2177
2214
  this.emit('bridge-disconnected');
2178
2215
  this._logEvent('bridge-disconnected', { reason });
2179
2216
  }
@@ -2182,6 +2219,9 @@ class CliProcess extends Process {
2182
2219
  this.closed = true;
2183
2220
  this.inFlight = false;
2184
2221
 
2222
+ this._stopQuestionKeepAlive(); // 0.12: drop the interactive-question keep-alive
2223
+ this._openQuestions.clear();
2224
+
2185
2225
  if (this.pingTimer) {
2186
2226
  clearInterval(this.pingTimer);
2187
2227
  this.pingTimer = null;
@@ -2542,6 +2582,34 @@ class CliProcess extends Process {
2542
2582
  this._writeToBridge({ kind: 'perm_verdict', request_id: requestId, behavior });
2543
2583
  }
2544
2584
 
2585
+ // ─── interactive questions (0.12 ask) ─────────────────────────────
2586
+
2587
+ /**
2588
+ * Hand a question's answer back to the blocking `ask` tool call. `result` is
2589
+ * {answers:[...]} | {cancelled:true} | {timedout:true}. Stops the keep-alive
2590
+ * once no questions remain open. Called by pm.answerQuestion (from the handler).
2591
+ */
2592
+ writeQuestionAnswer(toolCallId, result) {
2593
+ this._openQuestions.delete(toolCallId);
2594
+ if (this._openQuestions.size === 0) this._stopQuestionKeepAlive();
2595
+ return this._writeToBridge({ kind: 'question_answer', tool_call_id: toolCallId, result: result ?? {} });
2596
+ }
2597
+
2598
+ _startQuestionKeepAlive() {
2599
+ if (this._questionKeepAliveTimer) return;
2600
+ this._questionKeepAliveTimer = setInterval(() => {
2601
+ if (this._openQuestions.size === 0) { this._stopQuestionKeepAlive(); return; }
2602
+ // claude is idle waiting on the answer → no tool hooks → reset the idle
2603
+ // ceiling (and reply-quiet window) so the turn isn't killed mid-question.
2604
+ this._extendQuietOnToolActivity();
2605
+ }, 60_000);
2606
+ this._questionKeepAliveTimer.unref?.();
2607
+ }
2608
+
2609
+ _stopQuestionKeepAlive() {
2610
+ if (this._questionKeepAliveTimer) { clearInterval(this._questionKeepAliveTimer); this._questionKeepAliveTimer = null; }
2611
+ }
2612
+
2545
2613
  // ─── socket plumbing ──────────────────────────────────────────────
2546
2614
 
2547
2615
  _writeToBridge(obj) {
@@ -2649,6 +2717,12 @@ class CliProcess extends Process {
2649
2717
  async _pollMidTurnDialogs() {
2650
2718
  if (this.closed) return;
2651
2719
  if (this.pendingTurns.size === 0) return; // no work to do when idle
2720
+ // 0.12 interactive questions: while an `ask` is open claude sits idle at the
2721
+ // prompt waiting on the tool result — so the pane shows no "esc to interrupt"
2722
+ // and the question's own echoed text (a "?"/numbered list/"Yes/No") would
2723
+ // false-trip the unknown-prompt heuristic + starve the STALL heartbeat. The
2724
+ // keyboard lives on Telegram; suppress the pane watchdog while a question is open.
2725
+ if (this._openQuestions.size > 0) return;
2652
2726
  if (!this.tmuxSession) return; // pre-spawn / post-kill
2653
2727
  if (typeof this.runner?.captureWide !== 'function') return;
2654
2728
 
@@ -53,6 +53,11 @@ const CALLBACK_TO_EVENT = {
53
53
  // posts/edits a "⏳ working in background" status message so a long job reads as
54
54
  // working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
55
55
  onBgWorkStatus: 'bg-work-status',
56
+ // 0.12 interactive questions: CliProcess emits 'question-asked'
57
+ // {sessionKey, chatId, threadId, turnId, toolCallId, questions} when claude calls
58
+ // the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
59
+ // the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
60
+ onQuestionAsked: 'question-asked',
56
61
  onQueueDrop: 'queue-drop',
57
62
  onThinking: 'thinking',
58
63
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -458,6 +463,14 @@ class ProcessManager {
458
463
  return p.injectUserMessage(opts);
459
464
  }
460
465
 
466
+ // 0.12 interactive questions: hand an answer back to a blocking `ask` tool call.
467
+ // Returns false if the session is gone (claude is dead → nothing to answer).
468
+ answerQuestion(sessionKey, toolCallId, result) {
469
+ const p = this.procs.get(sessionKey);
470
+ if (!p || p.closed || typeof p.writeQuestionAnswer !== 'function') return false;
471
+ return p.writeQuestionAnswer(toolCallId, result);
472
+ }
473
+
461
474
  steer(sessionKey, text, opts) {
462
475
  const p = this.procs.get(sessionKey);
463
476
  if (!p || p.closed) return false;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Interactive-question state machine + Telegram rendering (0.12 ask feature).
3
+ *
4
+ * Pure, I/O-free: given an `ask` tool call's questions and the accumulated state,
5
+ * produce the Telegram message body + inline keyboard for the CURRENT question,
6
+ * apply a button tap or a free-text reply, and assemble the final answer payload.
7
+ * All Telegram sends / DB writes / bridge writes live in the callers
8
+ * (lib/handlers/questions.js, callbacks.js, cli-process.js) — this module is the
9
+ * testable core.
10
+ *
11
+ * Design: docs/0.12.0-interactive-questions-design.md. Sequential, one question
12
+ * at a time (P1–P2: single-select, multiSelect, free-text "Other"). Body is
13
+ * rendered PLAIN-TEXT (no parse_mode) because option labels/descriptions are
14
+ * agent-authored and must not reach the Markdown→HTML pipeline (security finding).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const MAX_LABEL = 40; // Telegram button labels truncate ~64; keep well under
20
+ const MAX_OTHER = 1000; // cap on the user's free-text answer entering claude's context
21
+ const MAX_PREVIEW = 500;
22
+
23
+ function truncLabel(label) {
24
+ const s = String(label ?? '');
25
+ return s.length > MAX_LABEL ? s.slice(0, MAX_LABEL - 1) + '…' : s;
26
+ }
27
+
28
+ /** Initial state for an ask call's questions array. */
29
+ function initState(questions) {
30
+ return {
31
+ questions: Array.isArray(questions) ? questions : [],
32
+ qIndex: 0,
33
+ answers: [], // accumulated: [{ header, selected:[label...], other? }]
34
+ toggles: {}, // current multiSelect question: { '<optIndex>': true }
35
+ awaitingOther: false, // current question is in free-text capture mode
36
+ };
37
+ }
38
+
39
+ function currentQuestion(state) {
40
+ return state.questions[state.qIndex] || null;
41
+ }
42
+
43
+ function isDone(state) {
44
+ return state.qIndex >= state.questions.length;
45
+ }
46
+
47
+ /**
48
+ * Render the CURRENT question as { text, reply_markup }. callbackBase is the
49
+ * `q:<qid>:<token>` prefix; actions append `:opt:<i>` / `:submit` / `:other`.
50
+ * Returns null when the set is already done.
51
+ */
52
+ function renderCurrent(state, callbackBase) {
53
+ const q = currentQuestion(state);
54
+ if (!q) return null;
55
+ const multi = q.multiSelect === true;
56
+ const total = state.questions.length;
57
+ const opts = Array.isArray(q.options) ? q.options : [];
58
+
59
+ const lines = [];
60
+ if (total > 1) lines.push(`Question ${state.qIndex + 1} of ${total}`);
61
+ if (q.header) lines.push(String(q.header));
62
+ lines.push(String(q.question ?? ''));
63
+ lines.push('');
64
+ opts.forEach((o, i) => {
65
+ const mark = multi && state.toggles[i] ? '☑️ ' : '• ';
66
+ lines.push(`${mark}${truncLabel(o.label)}${o.description ? ` — ${o.description}` : ''}`);
67
+ if (o.preview) lines.push(String(o.preview).slice(0, MAX_PREVIEW));
68
+ });
69
+ if (multi) lines.push('\nTap options to toggle, then Submit.');
70
+
71
+ const rows = opts.map((o, i) => ([{
72
+ text: `${multi && state.toggles[i] ? '☑️ ' : ''}${truncLabel(o.label)}`,
73
+ callback_data: `${callbackBase}:opt:${i}`,
74
+ }]));
75
+ if (multi) {
76
+ const any = Object.values(state.toggles).some(Boolean);
77
+ rows.push([{
78
+ text: any ? '✅ Submit' : '✅ Submit (pick at least one)',
79
+ callback_data: `${callbackBase}:submit`,
80
+ }]);
81
+ }
82
+ if (q.allowOther !== false) {
83
+ rows.push([{ text: '✏️ Type my own', callback_data: `${callbackBase}:other` }]);
84
+ }
85
+
86
+ return { text: lines.join('\n'), reply_markup: { inline_keyboard: rows } };
87
+ }
88
+
89
+ /** Parse the action suffix of a callback (everything after `q:<qid>:<token>:`). */
90
+ function parseAction(suffix) {
91
+ const s = String(suffix ?? '');
92
+ if (s === 'submit') return { type: 'submit' };
93
+ if (s === 'other') return { type: 'other' };
94
+ const m = s.match(/^opt:(\d+)$/);
95
+ if (m) return { type: 'opt', i: Number(m[1]) };
96
+ return { type: 'unknown' };
97
+ }
98
+
99
+ function recordAndAdvance(state, answer) {
100
+ const next = {
101
+ ...state,
102
+ answers: [...state.answers, answer],
103
+ qIndex: state.qIndex + 1,
104
+ toggles: {},
105
+ awaitingOther: false,
106
+ };
107
+ return next;
108
+ }
109
+
110
+ /**
111
+ * Apply a button tap. Returns:
112
+ * { state, kind:'toggled' } — multiSelect toggle, re-render
113
+ * { state, kind:'awaiting-other' } — user wants to type their own
114
+ * { state, kind:'reject', message } — invalid (e.g. submit with none)
115
+ * { state, kind:'advanced', receipt, done } — answer recorded; done if set complete
116
+ */
117
+ function applyTap(state, action) {
118
+ const q = currentQuestion(state);
119
+ if (!q) return { state, kind: 'reject', message: 'No active question.' };
120
+ const opts = Array.isArray(q.options) ? q.options : [];
121
+ const multi = q.multiSelect === true;
122
+
123
+ if (action.type === 'other') {
124
+ if (q.allowOther === false) return { state, kind: 'reject', message: 'Free text not allowed here.' };
125
+ return { state: { ...state, awaitingOther: true }, kind: 'awaiting-other' };
126
+ }
127
+
128
+ if (action.type === 'opt') {
129
+ if (action.i < 0 || action.i >= opts.length) {
130
+ return { state, kind: 'reject', message: 'Unknown option.' };
131
+ }
132
+ if (multi) {
133
+ const toggles = { ...state.toggles };
134
+ if (toggles[action.i]) delete toggles[action.i]; else toggles[action.i] = true;
135
+ return { state: { ...state, toggles }, kind: 'toggled' };
136
+ }
137
+ // single-select: record + advance
138
+ const label = opts[action.i].label;
139
+ const next = recordAndAdvance(state, { header: q.header, selected: [label] });
140
+ return { state: next, kind: 'advanced', receipt: label, done: isDone(next) };
141
+ }
142
+
143
+ if (action.type === 'submit') {
144
+ if (!multi) return { state, kind: 'reject', message: 'Nothing to submit.' };
145
+ const picked = Object.keys(state.toggles).filter(k => state.toggles[k]).map(Number).sort((a, b) => a - b);
146
+ if (picked.length === 0) return { state, kind: 'reject', message: 'Pick at least one option.' };
147
+ const labels = picked.map(i => opts[i].label);
148
+ const next = recordAndAdvance(state, { header: q.header, selected: labels });
149
+ return { state: next, kind: 'advanced', receipt: labels.join(', '), done: isDone(next) };
150
+ }
151
+
152
+ return { state, kind: 'reject', message: 'Unknown action.' };
153
+ }
154
+
155
+ /**
156
+ * Apply a free-text reply (the user's "Other" answer). Only valid when the
157
+ * current question is in awaitingOther mode. Returns the same advanced shape.
158
+ */
159
+ function applyFreeText(state, text) {
160
+ const q = currentQuestion(state);
161
+ if (!q || !state.awaitingOther) return { state, kind: 'reject', message: 'Not awaiting a typed answer.' };
162
+ const other = String(text ?? '').slice(0, MAX_OTHER);
163
+ const next = recordAndAdvance(state, { header: q.header, selected: [], other });
164
+ return { state: next, kind: 'advanced', receipt: other, done: isDone(next) };
165
+ }
166
+
167
+ /** Final tool result once the set is done. */
168
+ function assemble(state) {
169
+ return { answers: state.answers };
170
+ }
171
+
172
+ module.exports = {
173
+ initState,
174
+ currentQuestion,
175
+ isDone,
176
+ renderCurrent,
177
+ parseAction,
178
+ applyTap,
179
+ applyFreeText,
180
+ assemble,
181
+ MAX_LABEL,
182
+ MAX_OTHER,
183
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * pending_questions store — persistence for the 0.12 interactive-question flow.
3
+ *
4
+ * Mirrors lib/approvals/store.js: per-row 128-bit callback token, status
5
+ * lifecycle, audit-kept rows (never deleted; 'pending' at boot → 'expired').
6
+ * One OPEN question per session at a time. The answer routes back to claude on
7
+ * tool_call_id (a `question_answer` bridge message).
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { newToken, tokensEqual } = require('../approvals/store');
13
+
14
+ const DEFAULT_TIMEOUT_MS = 8 * 60 * 1000; // under the 10-min idle / 30-min absolute turn caps
15
+
16
+ function createQuestionStore(rawDb, now = () => Date.now()) {
17
+ const insertStmt = rawDb.prepare(`
18
+ INSERT INTO pending_questions (
19
+ bot_name, session_key, chat_id, thread_id, turn_id, tool_call_id,
20
+ callback_token, questions_json, state_json, created_ts, timeout_ts
21
+ ) VALUES (
22
+ @bot_name, @session_key, @chat_id, @thread_id, @turn_id, @tool_call_id,
23
+ @callback_token, @questions_json, @state_json, @created_ts, @timeout_ts
24
+ )
25
+ `);
26
+ const getByIdStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE id = ?`);
27
+ const getOpenForSessStmt = rawDb.prepare(`
28
+ SELECT * FROM pending_questions WHERE session_key = ? AND status = 'pending'
29
+ ORDER BY created_ts DESC LIMIT 1`);
30
+ const getByToolCallStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE tool_call_id = ? LIMIT 1`);
31
+ const setMsgIdsStmt = rawDb.prepare(`UPDATE pending_questions SET message_ids_json = ? WHERE id = ?`);
32
+ const updateStateStmt = rawDb.prepare(`
33
+ UPDATE pending_questions SET state_json = @state_json, awaiting_other = @awaiting_other
34
+ WHERE id = @id AND status = 'pending'`);
35
+ const claimStmt = rawDb.prepare(`
36
+ UPDATE pending_questions SET from_id = @from_id
37
+ WHERE id = @id AND from_id IS NULL AND status = 'pending'`);
38
+ const resolveStmt = rawDb.prepare(`
39
+ UPDATE pending_questions SET status = @status, answered_ts = @answered_ts
40
+ WHERE id = @id AND status = 'pending'`);
41
+ const listTimedOutStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE status = 'pending' AND timeout_ts < ?`);
42
+ const listOpenStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE bot_name = ? AND status = 'pending'`);
43
+
44
+ return {
45
+ issue({ bot_name, session_key, chat_id, thread_id = null, turn_id = null, tool_call_id, questions, state, timeoutMs = DEFAULT_TIMEOUT_MS }) {
46
+ if (!bot_name || !session_key || !chat_id || !tool_call_id) {
47
+ throw new Error('issue: bot_name, session_key, chat_id, tool_call_id required');
48
+ }
49
+ const created_ts = now();
50
+ const res = insertStmt.run({
51
+ bot_name,
52
+ session_key,
53
+ chat_id: String(chat_id),
54
+ thread_id: thread_id != null ? String(thread_id) : null,
55
+ turn_id,
56
+ tool_call_id,
57
+ callback_token: newToken(),
58
+ questions_json: JSON.stringify(questions ?? []),
59
+ state_json: JSON.stringify(state ?? {}),
60
+ created_ts,
61
+ timeout_ts: created_ts + timeoutMs,
62
+ });
63
+ return getByIdStmt.get(res.lastInsertRowid);
64
+ },
65
+
66
+ getById(id) { return getByIdStmt.get(id); },
67
+ getOpenForSession(session_key) { return getOpenForSessStmt.get(session_key); },
68
+ getByToolCallId(tool_call_id) { return getByToolCallStmt.get(tool_call_id); },
69
+ setMessageIds(id, ids) { return setMsgIdsStmt.run(JSON.stringify(ids ?? []), id).changes; },
70
+
71
+ updateState(id, state, awaitingOther = false) {
72
+ return updateStateStmt.run({ id, state_json: JSON.stringify(state ?? {}), awaiting_other: awaitingOther ? 1 : 0 }).changes;
73
+ },
74
+
75
+ /**
76
+ * Authorize a responder. Claim-on-first-tap: if no from_id is recorded yet,
77
+ * the first interacting user claims the question; thereafter only that user
78
+ * may answer. Returns { ok, claimed }.
79
+ */
80
+ claimOrCheck(id, from_id) {
81
+ if (from_id == null) return { ok: false, claimed: false };
82
+ const claimed = claimStmt.run({ id, from_id }).changes > 0;
83
+ if (claimed) return { ok: true, claimed: true };
84
+ const row = getByIdStmt.get(id);
85
+ return { ok: row && Number(row.from_id) === Number(from_id), claimed: false };
86
+ },
87
+
88
+ resolve(id, status) {
89
+ if (!['answered', 'cancelled', 'timeout', 'expired'].includes(status)) {
90
+ throw new Error(`bad status: ${status}`);
91
+ }
92
+ return resolveStmt.run({ id, status, answered_ts: now() }).changes;
93
+ },
94
+
95
+ sweepTimedOut() { return listTimedOutStmt.all(now()); },
96
+ listOpen(bot_name) { return listOpenStmt.all(bot_name); },
97
+ };
98
+ }
99
+
100
+ module.exports = { createQuestionStore, tokensEqual, DEFAULT_TIMEOUT_MS };
@@ -50,6 +50,9 @@ function createSdkCallbacks({
50
50
  chunkMarkdownText,
51
51
  deliverReplies,
52
52
  processAndDeliverAgentText,
53
+ // 0.12 interactive questions: (payload) => renders the Telegram keyboard when
54
+ // claude calls the `ask` tool. Optional — omitted in tests / SDK-only callers.
55
+ renderQuestion,
53
56
  logger = console,
54
57
  } = {}) {
55
58
  // rc.9: typing-indicator state for autosteer NEW-TURN extraction.
@@ -345,6 +348,19 @@ function createSdkCallbacks({
345
348
  // status message and edit it to done — so a long job reads as working, not
346
349
  // stuck. Direct tg send (NOT via claude — this is a bot status indicator),
347
350
  // keyed by sessionKey so the cleared/close paths can find it to edit.
351
+ // 0.12 interactive questions: claude called the `ask` tool. Render the
352
+ // Telegram inline keyboard via the question handler (late-bound from polygram).
353
+ // payload: {chatId, threadId, turnId, toolCallId, questions}. The handler
354
+ // itself is anti-hang (answers claude {cancelled} on any send failure).
355
+ onQuestionAsked: async (sessionKey, payload) => {
356
+ try {
357
+ if (typeof renderQuestion !== 'function') return;
358
+ await renderQuestion({ sessionKey, ...payload });
359
+ } catch (err) {
360
+ logger.error?.(`[${botName}] onQuestionAsked failed: ${err.message}`);
361
+ }
362
+ },
363
+
348
364
  onBgWorkStatus: async (sessionKey, payload) => {
349
365
  try {
350
366
  if (!bot) return;
@@ -0,0 +1,30 @@
1
+ -- 0.12 interactive questions: AskUserQuestion → Telegram inline keyboards.
2
+ -- Mirrors pending_approvals (per-row 128-bit callback token, status lifecycle,
3
+ -- audit-kept rows). One OPEN question per session at a time (idx_pq_open).
4
+ -- The answer routes back to claude on tool_call_id via a question_answer message.
5
+
6
+ CREATE TABLE pending_questions (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ bot_name TEXT NOT NULL,
9
+ session_key TEXT NOT NULL, -- chat:thread that asked (claude session)
10
+ chat_id TEXT NOT NULL,
11
+ thread_id TEXT,
12
+ turn_id TEXT, -- echoed for routing
13
+ tool_call_id TEXT NOT NULL, -- the question_answer routes back on this
14
+ from_id INTEGER, -- Telegram user_id allowed to answer; claimed on first tap
15
+ callback_token TEXT NOT NULL, -- 128-bit; defeats forged/guessed callback_data
16
+ questions_json TEXT NOT NULL, -- the ask call's questions array
17
+ state_json TEXT NOT NULL, -- the question-state machine state
18
+ message_ids_json TEXT, -- Telegram msg_id(s) of the keyboard message to edit/strip
19
+ awaiting_other INTEGER NOT NULL DEFAULT 0, -- 1 while capturing a free-text "Other"
20
+ status TEXT NOT NULL
21
+ CHECK(status IN ('pending','answered','cancelled','timeout','expired'))
22
+ DEFAULT 'pending',
23
+ created_ts INTEGER NOT NULL,
24
+ timeout_ts INTEGER NOT NULL,
25
+ answered_ts INTEGER
26
+ );
27
+
28
+ CREATE INDEX idx_pq_open ON pending_questions(session_key, status) WHERE status = 'pending';
29
+ CREATE INDEX idx_pq_timeout ON pending_questions(status, timeout_ts) WHERE status = 'pending';
30
+ CREATE INDEX idx_pq_tool_call ON pending_questions(tool_call_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.29",
3
+ "version": "0.12.0-rc.30",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -56,6 +56,8 @@ const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
56
56
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
57
57
  const { createBuildSdkOptions } = require('./lib/sdk/build-options');
58
58
  const { createSdkCallbacks } = require('./lib/sdk/callbacks');
59
+ const { createQuestionStore } = require('./lib/questions/store');
60
+ const { createQuestionHandlers } = require('./lib/handlers/questions');
59
61
  const { createTranscribeVoiceAttachments } = require('./lib/handlers/voice');
60
62
  const { createDownloadAttachments } = require('./lib/handlers/download');
61
63
  const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
@@ -644,6 +646,9 @@ let maybeInjectEditCorrection = null;
644
646
  // approvals store are available.
645
647
  let makeCanUseTool = null;
646
648
  let handleApprovalCallback = null;
649
+ // 0.12 interactive questions — assigned in main() once db.raw + pm exist; the
650
+ // createSdkCallbacks onQuestionAsked closure + the callback router read it late.
651
+ let questionHandlers = null;
647
652
  let resolveApprovalWaiter = null;
648
653
  let startApprovalSweeper = null;
649
654
  let cancelAllWaiters = null;
@@ -1854,14 +1859,29 @@ function createBot(token) {
1854
1859
  return;
1855
1860
  }
1856
1861
 
1857
- if (!shouldHandle(msg, chatConfig, botUsername)) return;
1862
+ const threadId = msg.message_thread_id?.toString();
1863
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1864
+
1865
+ // 0.12 interactive questions: the owner's free-text "Other" answer arrives
1866
+ // WITHOUT an @mention, so in a mention-gated group shouldHandle would reject
1867
+ // it and the "Other" flow would silently dead-end. Let the claimed owner's
1868
+ // typed answer bypass the gate (owner-only; bystanders still respect it).
1869
+ const ownsOpenOther = questionHandlers
1870
+ ? questionHandlers.isAwaitingOtherFrom(sessionKey, msg.from?.id)
1871
+ : false;
1872
+ if (!ownsOpenOther && !shouldHandle(msg, chatConfig, botUsername)) return;
1858
1873
 
1859
1874
  if (botUsername) {
1860
1875
  msg.text = cleanText;
1861
1876
  }
1862
1877
 
1863
- const threadId = msg.message_thread_id?.toString();
1864
- const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1878
+ // A typed message while a question is in free-text capture for this
1879
+ // session+user becomes the answer (not a new turn). Only an in-progress
1880
+ // "Other" diverts; ordinary chatter and /commands fall through.
1881
+ if (questionHandlers) {
1882
+ const r = await questionHandlers.tryConsumeAsAnswer({ sessionKey, fromId: msg.from?.id, text: cleanText });
1883
+ if (r.consumed) return;
1884
+ }
1865
1885
  dispatchHandleMessage(sessionKey, chatId, msg, bot);
1866
1886
  };
1867
1887
 
@@ -1975,6 +1995,8 @@ function createBot(token) {
1975
1995
  const data = ctx.callbackQuery.data;
1976
1996
  if (data.startsWith('cfg:')) {
1977
1997
  await handleConfigCallback(ctx);
1998
+ } else if (data.startsWith('q:')) {
1999
+ if (questionHandlers) await questionHandlers.handleQuestionCallback(ctx);
1978
2000
  } else {
1979
2001
  await handleApprovalCallback(ctx);
1980
2002
  }
@@ -2234,6 +2256,9 @@ async function main() {
2234
2256
  // `[react:EMOJI]`, `No response requested.` all leaked as literal text.
2235
2257
  parseResponse, sanitizeAssistantReply, chunkMarkdownText, deliverReplies,
2236
2258
  processAndDeliverAgentText,
2259
+ // 0.12 interactive questions: 'question-asked' (claude called the ask tool)
2260
+ // → render the Telegram keyboard. Late-bound; questionHandlers is assigned below.
2261
+ renderQuestion: (payload) => questionHandlers?.renderAsk(payload),
2237
2262
  logger: console,
2238
2263
  });
2239
2264
  // 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
@@ -2253,6 +2278,23 @@ async function main() {
2253
2278
  config, db, bot, botName: BOT_NAME, tg, logEvent,
2254
2279
  approvals, getChatIdFromKey, logger: console,
2255
2280
  }));
2281
+
2282
+ // 0.12 interactive questions: store + handlers + timeout sweep. answerQuestion
2283
+ // is late-bound to pm (a tap can land minutes later, pm is live by then).
2284
+ const questionStore = createQuestionStore(db.raw);
2285
+ questionHandlers = createQuestionHandlers({
2286
+ questions: questionStore, tg, bot, botName: BOT_NAME, logEvent,
2287
+ answerQuestion: (sk, tc, result) => pm.answerQuestion(sk, tc, result),
2288
+ logger: console,
2289
+ });
2290
+ // Resolve expired questions with {timedout} so claude never hangs on an ignored ask.
2291
+ setInterval(() => {
2292
+ try {
2293
+ for (const row of questionStore.sweepTimedOut()) {
2294
+ questionHandlers.expireQuestion(row).catch((e) => console.error(`[${BOT_NAME}] question expire: ${e.message}`));
2295
+ }
2296
+ } catch (e) { console.error(`[${BOT_NAME}] question sweep: ${e.message}`); }
2297
+ }, 30_000).unref?.();
2256
2298
  buildSdkOptions = createBuildSdkOptions({
2257
2299
  config,
2258
2300
  botName: BOT_NAME,