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 +1 -1
- package/lib/handlers/questions.js +287 -0
- package/lib/process/channels-bridge-protocol.js +18 -1
- package/lib/process/channels-bridge.mjs +85 -1
- package/lib/process/cli-process.js +77 -3
- package/lib/process-manager.js +13 -0
- package/lib/questions/questions.js +183 -0
- package/lib/questions/store.js +100 -0
- package/lib/sdk/callbacks.js +16 -0
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +45 -3
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 =
|
|
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
|
-
|
|
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,
|
|
706
|
-
'
|
|
707
|
-
'
|
|
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
|
|
package/lib/process-manager.js
CHANGED
|
@@ -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 };
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
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,
|