polygram 0.12.0-rc.9 → 0.12.0

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.
Files changed (46) hide show
  1. package/config.example.json +3 -1
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -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.9",
3
+ "version": "0.12.0",
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,12 +56,21 @@ 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');
61
+ const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
62
+ const { createRewindExecutor } = require('./lib/rewind/execute');
59
63
  const { createTranscribeVoiceAttachments } = require('./lib/handlers/voice');
60
64
  const { createDownloadAttachments } = require('./lib/handlers/download');
61
65
  const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
62
66
  const { createHandleAbort } = require('./lib/handlers/abort');
63
67
  const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
64
68
  const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
69
+ const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
70
+ const { createGateInbound } = require('./lib/handlers/gate-inbound');
71
+ const { createRedeliver } = require('./lib/handlers/redeliver');
72
+ const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
73
+ const { createSessionFeedback } = require('./lib/feedback/session-feedback');
65
74
  const { createSlashCommands } = require('./lib/handlers/slash-commands');
66
75
  const { createApprovals } = require('./lib/handlers/approvals');
67
76
  const { canonicalizeToolInput } = require('./lib/canonical-json');
@@ -97,6 +106,7 @@ const { startTyping } = require('./lib/telegram/typing');
97
106
  // consumer is lib/handlers/download.js.
98
107
  const { createReactionManager, classifyToolName } = require('./lib/telegram/reactions');
99
108
  const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
109
+ const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
100
110
  const { classify: classifyError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
101
111
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
102
112
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
@@ -334,6 +344,9 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
334
344
  db,
335
345
  chatId,
336
346
  threadId: threadId || null,
347
+ // Per-topic sessions must only preload their OWN thread — else a message
348
+ // in one topic gets fed other topics' history (2026-06-09 cross-topic bleed).
349
+ isolateTopics: chatConfig?.isolateTopics === true,
337
350
  excludeMsgId: msg.message_id,
338
351
  logger: console,
339
352
  });
@@ -635,6 +648,13 @@ let handleAbortIfRequested = null;
635
648
  let autosteer = null;
636
649
  let dispatchSlashCommand = null;
637
650
  let maybeInjectEditCorrection = null;
651
+ let maybePostTurnEdit = null;
652
+ // 0.13 D5/D4: the ONE intake gate (assigned in createBot, where botUsername/
653
+ // mentionRe live) + the ONE redelivery tail (assigned in main once the
654
+ // dispatcher exists). See lib/handlers/gate-inbound.js / redeliver.js.
655
+ let gateInbound = null;
656
+ let redeliverAsFreshTurn = null;
657
+ let dropRedeliverer = null; // 0.13 D2→D4 glue; assigned once redeliver exists
638
658
 
639
659
  // rc.20: approvalCardText + safeParse moved to lib/approvals/ui.js.
640
660
  // 0.9.0 commit 29: makeCanUseTool / handleApprovalCallback /
@@ -643,6 +663,10 @@ let maybeInjectEditCorrection = null;
643
663
  // approvals store are available.
644
664
  let makeCanUseTool = null;
645
665
  let handleApprovalCallback = null;
666
+ // 0.12 interactive questions — assigned in main() once db.raw + pm exist; the
667
+ // createSdkCallbacks onQuestionAsked closure + the callback router read it late.
668
+ let questionHandlers = null;
669
+ let rewindHandler = null; // 0.13 /rewind (P1); late-bound, assigned in main() after pm exists
646
670
  let resolveApprovalWaiter = null;
647
671
  let startApprovalSweeper = null;
648
672
  let cancelAllWaiters = null;
@@ -739,8 +763,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
739
763
 
740
764
  if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
741
765
  const show = text === '/effort' ? 'effort' : text === '/model' ? 'model' : 'all';
742
- const info = formatConfigInfoText(chatConfig, show, sessionKey);
743
- const reply_markup = buildConfigKeyboard(chatConfig, show);
766
+ // Resolve per-topic overrides so a topic's card shows its REAL
767
+ // agent/model/effort, not the chat-level default — Music topic (thread 3)
768
+ // showed "Agent: shumabit" instead of music-curation:music-curator
769
+ // (2026-06-03). getTopicConfig returns {} when there's no active topic.
770
+ const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
771
+ const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg);
772
+ const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg);
744
773
  await sendReply(info, { params: { reply_markup } });
745
774
  return;
746
775
  }
@@ -998,13 +1027,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
998
1027
  const availableEmojis = await getReactionAllowlist(bot, chatId);
999
1028
  const reactor = createReactionManager({
1000
1029
  apply: async (emoji) => {
1001
- const params = {
1002
- chat_id: chatId,
1003
- message_id: msg.message_id,
1004
- reaction: emoji ? [{ type: 'emoji', emoji }] : [],
1005
- };
1006
- await tg(bot, 'setMessageReaction', params,
1007
- { source: 'status-reaction', botName: BOT_NAME });
1030
+ // rc.16: mirror the reaction onto album siblings too, so a multi-file
1031
+ // send shows the same status emoji on EVERY item, not just the anchor.
1032
+ // For a normal single message, _albumSiblingMsgIds is undefined and this
1033
+ // is exactly the prior single setMessageReaction. Anchor is awaited
1034
+ // (failure surfaces to the reactor); siblings are best-effort.
1035
+ await applyReactionToMessages({
1036
+ tg, bot, chatId,
1037
+ msgIds: [msg.message_id, ...(msg._albumSiblingMsgIds || [])],
1038
+ emoji,
1039
+ botName: BOT_NAME,
1040
+ });
1008
1041
  },
1009
1042
  availableEmojis,
1010
1043
  logError: (m) => console.error(`[${label}] ${m}`),
@@ -1097,7 +1130,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1097
1130
  // pick them off entry.pendingQueue[0].context.
1098
1131
  await new Promise((dispatched) => {
1099
1132
  sendPromise = sendToProcess(sessionKey, prompt, {
1100
- streamer, reactor, sourceMsgId: msg.message_id,
1133
+ // 0.13 D1 (S8): the typing controller rides the per-turn context so
1134
+ // the question lifecycle (callbacks.js onQuestionAsked/-Resumed) can
1135
+ // pause it while the bot waits on the USER and resume on the answer.
1136
+ streamer, reactor, typing: stopTyping, sourceMsgId: msg.message_id,
1101
1137
  // 0.7.4 (item B): fire THINKING when Claude actually starts
1102
1138
  // emitting — not the moment we wrote stdin.
1103
1139
  onFirstStream: () => reactor.setState('THINKING'),
@@ -1698,7 +1734,14 @@ function createBot(token) {
1698
1734
  const apiRoot = config.bot?.apiRoot;
1699
1735
  const bot = new Bot(token, {
1700
1736
  client: {
1701
- timeoutSeconds: 60,
1737
+ // rc.15: with the local Bot API server, getFile DOWNLOADS the file
1738
+ // synchronously (server fetches it from Telegram's DC, then responds) —
1739
+ // a large lossless WAV can take >60s, so the cloud-tuned 60s timeout
1740
+ // fired before the download finished (the file still landed on the
1741
+ // server's disk, but polygram's getFile call already errored). The
1742
+ // local server is localhost, so non-download calls stay fast; the
1743
+ // higher ceiling only matters for big getFile downloads.
1744
+ timeoutSeconds: apiRoot ? 180 : 60,
1702
1745
  ...(apiRoot ? { apiRoot } : {}),
1703
1746
  },
1704
1747
  });
@@ -1708,9 +1751,6 @@ function createBot(token) {
1708
1751
  let botUsername = '';
1709
1752
  // Cached once @botUsername is known — was recompiling per inbound msg.
1710
1753
  let mentionRe = null;
1711
- // Hoisted admin-command matcher; was re-allocated per message.
1712
- const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
1713
- const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
1714
1754
 
1715
1755
  // The filter in main() guarantees config.chats only contains chats owned
1716
1756
  // by BOT_NAME, so any update for a chat not in config.chats is unknown —
@@ -1808,45 +1848,38 @@ function createBot(token) {
1808
1848
  return newChat;
1809
1849
  }
1810
1850
 
1851
+ // 0.13 D5: the intake chain (abort → admin/pair → rewind → ownsOpenOther ‖
1852
+ // shouldHandle → question-consume → dispatch) moved verbatim into the ONE
1853
+ // gate, lib/handlers/gate-inbound.js, with a tier×stage side-effect table —
1854
+ // the edited_message path and every redelivery now run the SAME chain
1855
+ // (pre-0.13 they ran divergent subsets; the divergences were bugs — an edit
1856
+ // to "/stop" was injected into the very turn it tried to kill, an edit
1857
+ // during an open "Other" capture never became the answer, and any group
1858
+ // member's bare "stop" aborted others' turns pre-gate). Late-bound deps are
1859
+ // getters because this runs at createBot time, before main() wires the
1860
+ // dispatcher/handlers.
1861
+ gateInbound = createGateInbound({
1862
+ config,
1863
+ getBotUsername: () => botUsername,
1864
+ getMentionRe: () => mentionRe,
1865
+ pairings: { hasLivePairing: (args) => !!pairings?.hasLivePairing(args) },
1866
+ isAbortRequest,
1867
+ handleAbortIfRequested: (...a) => handleAbortIfRequested(...a),
1868
+ getRewindHandler: () => rewindHandler,
1869
+ isRewindCommand,
1870
+ getQuestionHandlers: () => questionHandlers,
1871
+ shouldHandle,
1872
+ getSessionKey,
1873
+ dispatchHandleMessage: (...a) => dispatchHandleMessage(...a),
1874
+ bot,
1875
+ botName: BOT_NAME,
1876
+ logEvent,
1877
+ logger: console,
1878
+ });
1879
+
1811
1880
  // Shared post-validation dispatch. Called directly for single messages
1812
1881
  // and for the synthesised "primary" of a media-group bundle.
1813
- const dispatchRegularMessage = async (msg) => {
1814
- const chatId = msg.chat.id.toString();
1815
- const chatConfig = config.chats[chatId];
1816
- if (!chatConfig) return;
1817
-
1818
- const rawText = msg.text || '';
1819
- const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
1820
-
1821
- // Abort: skip the queue entirely. Matches bilingual natural-
1822
- // language cues + slash variants. handleAbortIfRequested
1823
- // (lib/handlers/abort.js) returns true when handled — short-
1824
- // circuit out of dispatch.
1825
- if (await handleAbortIfRequested(msg, chatId, chatConfig, cleanText)) {
1826
- return;
1827
- }
1828
-
1829
- const botAllowsCommands = !!config.bot?.allowConfigCommands;
1830
- const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
1831
- const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
1832
- if (isAdminCmd || isPairClaim) {
1833
- msg.text = cleanText;
1834
- const threadId = msg.message_thread_id?.toString();
1835
- const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1836
- await handleMessage(sessionKey, chatId, msg, bot);
1837
- return;
1838
- }
1839
-
1840
- if (!shouldHandle(msg, chatConfig, botUsername)) return;
1841
-
1842
- if (botUsername) {
1843
- msg.text = cleanText;
1844
- }
1845
-
1846
- const threadId = msg.message_thread_id?.toString();
1847
- const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1848
- dispatchHandleMessage(sessionKey, chatId, msg, bot);
1849
- };
1882
+ const dispatchRegularMessage = async (msg) => gateInbound(msg, { tier: 'fresh' });
1850
1883
 
1851
1884
  // Media-group buffer: coalesce multi-photo uploads (Telegram delivers
1852
1885
  // each attachment as a separate Message sharing a `media_group_id`) into
@@ -1885,6 +1918,10 @@ function createBot(token) {
1885
1918
  }
1886
1919
 
1887
1920
  const synthetic = { ...primary, _mergedAttachments: merged };
1921
+ // rc.16: carry the album sibling msg_ids so the status reactor can mirror
1922
+ // its emoji onto every item (not just the anchor) — see the reactor
1923
+ // `apply` closure + lib/telegram/album-reactions.js.
1924
+ if (siblingMsgIds.length) synthetic._albumSiblingMsgIds = siblingMsgIds;
1888
1925
  // Carry the primary's text verbatim (dispatchRegularMessage re-cleans
1889
1926
  // the mention). Caption → text so downstream sees it uniformly.
1890
1927
  if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;
@@ -1954,6 +1991,8 @@ function createBot(token) {
1954
1991
  const data = ctx.callbackQuery.data;
1955
1992
  if (data.startsWith('cfg:')) {
1956
1993
  await handleConfigCallback(ctx);
1994
+ } else if (data.startsWith('q:')) {
1995
+ if (questionHandlers) await questionHandlers.handleQuestionCallback(ctx);
1957
1996
  } else {
1958
1997
  await handleApprovalCallback(ctx);
1959
1998
  }
@@ -1973,6 +2012,10 @@ function createBot(token) {
1973
2012
  }
1974
2013
  const chatId = ctx.editedMessage.chat.id.toString();
1975
2014
  if (!knownChat(chatId)) return;
2015
+ // 0.12.0 spec §3 (HARD): read the OLD text BEFORE recordInbound overwrites the row — the
2016
+ // post-turn changed-guard compares it, and the re-dispatch quotes it in reply_to so claude sees
2017
+ // the before/after. Reading after recordInbound would yield the new text (useless).
2018
+ const oldText = db.getMessage(chatId, ctx.editedMessage.message_id)?.text ?? null;
1976
2019
  recordInbound(ctx.editedMessage);
1977
2020
  logEvent('message-edited', {
1978
2021
  chat_id: chatId,
@@ -1981,16 +2024,29 @@ function createBot(token) {
1981
2024
  });
1982
2025
  console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
1983
2026
 
1984
- // 0.9.0: typo-correction injection. If the SDK still has this turn
1985
- // in flight (handler_status in dispatched/processing AND
1986
- // pm.get(sk).inFlight), inject a `[edit] corrected: <NEW>` note
1987
- // via the same channel autosteer uses. Lets users fix typos
1988
- // mid-turn without /stop + resend. No-op when the turn already
1989
- // completed.
2027
+ // 0.13 D5: gate FIRST (tier 'edit') — the edited message's CURRENT text
2028
+ // runs the same abort/admin/question-consume/shouldHandle chain as a fresh
2029
+ // message. Closes the S11 holes: an edit-to-"stop" now ABORTS (identity-
2030
+ // gated) instead of being injected into the very turn it tries to kill; an
2031
+ // edit while that user owns an open free-text "Other" capture becomes the
2032
+ // answer; a bystander's un-addressed edit is blocked BEFORE any inject.
2033
+ // Only a 'pass' proceeds to the fold/redeliver machinery below.
1990
2034
  try {
1991
- maybeInjectEditCorrection?.(ctx.editedMessage);
2035
+ const gateRes = await gateInbound(ctx.editedMessage, { tier: 'edit' });
2036
+ if (gateRes.action !== 'pass') {
2037
+ logEvent('edit-gated', {
2038
+ chat_id: chatId, msg_id: ctx.editedMessage.message_id,
2039
+ action: gateRes.action, stage: gateRes.stage ?? null,
2040
+ });
2041
+ return;
2042
+ }
2043
+ // Mid-turn (turn still in flight) → fold into the running turn via the 0.9.0
2044
+ // injector. Post-turn (idle) — OR the injector no-ops because the turn just
2045
+ // settled at the boundary — → re-dispatch as a NEW turn (edit re-delivery).
2046
+ const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
2047
+ if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText, botUsername, mentionRe);
1992
2048
  } catch (err) {
1993
- console.error(`[${BOT_NAME}] edit-correction injector error: ${err.message}`);
2049
+ console.error(`[${BOT_NAME}] edit handler error: ${err.message}`);
1994
2050
  }
1995
2051
  });
1996
2052
 
@@ -2204,8 +2260,17 @@ async function main() {
2204
2260
 
2205
2261
  bot = createBot(config.bot.token);
2206
2262
 
2263
+ // 0.13 D3: session-scoped feedback for cycles with NO pending turn
2264
+ // (ScheduleWakeup, fireUserMessage self-checks, injected messages picked up
2265
+ // as their own cycle) — typing for the cycle's duration + a 🤔 anchored to
2266
+ // the picked-up message when the input ledger names one.
2267
+ const sessionFeedback = createSessionFeedback({
2268
+ bot, tg, getChatIdFromKey, getThreadIdFromKey, botName: BOT_NAME,
2269
+ logEvent, logger: console,
2270
+ });
2271
+
2207
2272
  const sdkCallbacks = createSdkCallbacks({
2208
- db, dbWrite, config, bot, botName: BOT_NAME, tg, logEvent,
2273
+ db, dbWrite, config, bot, botName: BOT_NAME, tg, logEvent, sessionFeedback,
2209
2274
  classifyToolName, announce, shouldAnnounce, contextHintShown,
2210
2275
  extractAssistantText, getChatIdFromKey, getThreadIdFromKey,
2211
2276
  // F#23: enable parse/sanitize/sticker/react on the autonomous-wakeup path.
@@ -2213,6 +2278,9 @@ async function main() {
2213
2278
  // `[react:EMOJI]`, `No response requested.` all leaked as literal text.
2214
2279
  parseResponse, sanitizeAssistantReply, chunkMarkdownText, deliverReplies,
2215
2280
  processAndDeliverAgentText,
2281
+ // 0.12 interactive questions: 'question-asked' (claude called the ask tool)
2282
+ // → render the Telegram keyboard. Late-bound; questionHandlers is assigned below.
2283
+ renderQuestion: (payload) => questionHandlers?.renderAsk(payload),
2216
2284
  logger: console,
2217
2285
  });
2218
2286
  // 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
@@ -2232,6 +2300,32 @@ async function main() {
2232
2300
  config, db, bot, botName: BOT_NAME, tg, logEvent,
2233
2301
  approvals, getChatIdFromKey, logger: console,
2234
2302
  }));
2303
+
2304
+ // 0.12 interactive questions: store + handlers + timeout sweep. answerQuestion
2305
+ // is late-bound to pm (a tap can land minutes later, pm is live by then).
2306
+ const questionStore = createQuestionStore(db.raw);
2307
+ questionHandlers = createQuestionHandlers({
2308
+ questions: questionStore, tg, bot, botName: BOT_NAME, logEvent,
2309
+ answerQuestion: (sk, tc, result) => pm.answerQuestion(sk, tc, result),
2310
+ logger: console,
2311
+ });
2312
+ // Resolve expired questions with {timedout} so claude never hangs on an ignored ask.
2313
+ setInterval(() => {
2314
+ try {
2315
+ for (const row of questionStore.sweepTimedOut()) {
2316
+ questionHandlers.expireQuestion(row).catch((e) => console.error(`[${BOT_NAME}] question expire: ${e.message}`));
2317
+ }
2318
+ } catch (e) { console.error(`[${BOT_NAME}] question sweep: ${e.message}`); }
2319
+ }, 30_000).unref?.();
2320
+
2321
+ // 0.13 /rewind: detect + operator/ownership gate + turn-end defer + confirm (P1), backed by
2322
+ // the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
2323
+ // orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
2324
+ // See docs/0.13-rewind-design.md.
2325
+ const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
2326
+ rewindHandler = createRewindHandler({
2327
+ pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
2328
+ });
2235
2329
  buildSdkOptions = createBuildSdkOptions({
2236
2330
  config,
2237
2331
  botName: BOT_NAME,
@@ -2322,6 +2416,11 @@ async function main() {
2322
2416
  // the verdict back via tmux send-keys "1"/"3"+Enter.
2323
2417
  // makeCanUseTool handles admin card, chat_tool_decisions persistence,
2324
2418
  // and timeout race — all reused from SDK.
2419
+ // 0.13 D2: 'input-dropped' → redeliver once via the D4 tail. Stable wrapper:
2420
+ // pm spread-copies the callbacks object at construction, and the redeliver
2421
+ // tail is wired later in main() — the wrapper late-binds it.
2422
+ sdkCallbacks.onInputDropped = (sessionKey, payload) => dropRedeliverer?.(sessionKey, payload);
2423
+
2325
2424
  sdkCallbacks.onApprovalRequired = async (sessionKey, payload) => {
2326
2425
  const { toolName, toolInput, id, respond } = payload || {};
2327
2426
  if (typeof respond !== 'function') return;
@@ -2360,7 +2459,7 @@ async function main() {
2360
2459
  });
2361
2460
  handleConfigCallback = createHandleConfigCallback({
2362
2461
  config, db, dbWrite, pm, getSessionKey,
2363
- formatConfigInfoText, buildConfigKeyboard,
2462
+ formatConfigInfoText, buildConfigKeyboard, saveConfig,
2364
2463
  botName: BOT_NAME, logger: console,
2365
2464
  });
2366
2465
  handleAbortIfRequested = createHandleAbort({
@@ -2396,6 +2495,34 @@ async function main() {
2396
2495
  getIsShuttingDown: () => isShuttingDown,
2397
2496
  logger: console,
2398
2497
  }));
2498
+ // 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
2499
+ // An edit while a turn is in flight folds via maybeInjectEditCorrection; an edit after the turn
2500
+ // (or when the injector no-ops at the boundary) re-dispatches as a new turn. The on-edit 👀 is a
2501
+ // pre-turn ack for the cold-spawn gap; the synthetic turn's own reactor then takes over the msg.
2502
+ maybePostTurnEdit = createEditRedelivery({
2503
+ pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
2504
+ react: (chatId, msgId) => applyReactionToMessages({
2505
+ tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
2506
+ }).catch(() => {}),
2507
+ logEvent, logger: console,
2508
+ });
2509
+ // 0.13 D4: the ONE redelivery tail — boot-replay (below) and the P3
2510
+ // drop-redeliverer converge on it (once-only + _isReplay + redelivery-tier
2511
+ // gate + 👀 ack + dispatch). startup-auto-retry deliberately stays a
2512
+ // same-process re-dispatch (its error path must SURFACE the friendly reset
2513
+ // reply, which the _isReplay tag would suppress); compact-replay stays a
2514
+ // system re-push outside the user-message gate (design §6.7).
2515
+ redeliverAsFreshTurn = createRedeliver({
2516
+ gateInbound: (...a) => gateInbound(...a),
2517
+ dispatchHandleMessage, getSessionKey, config, db, dbWrite,
2518
+ react: (chatId, msgId) => applyReactionToMessages({
2519
+ tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
2520
+ }).catch(() => {}),
2521
+ bot, logEvent, logger: console,
2522
+ });
2523
+ dropRedeliverer = createDropRedeliverer({
2524
+ db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
2525
+ });
2399
2526
  ({ pollBot, startPollWatchdog } = createPollLoop({
2400
2527
  db, dbWrite, config, botName: BOT_NAME,
2401
2528
  isWellFormedMessage, getTopicName,
@@ -2405,7 +2532,7 @@ async function main() {
2405
2532
  config, db, dbWrite, pm, pairings, parsePairingTtl,
2406
2533
  contextHintShown, formatContextReply, getClaudeSessionId,
2407
2534
  getOrSpawnForChat, parsePairCodeArgs,
2408
- modelVersionsDesc: MODEL_VERSIONS_DESC,
2535
+ modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
2409
2536
  botName: BOT_NAME, logEvent, logger: console,
2410
2537
  });
2411
2538
  console.log('[polygram] using SDK ProcessManager');
@@ -2448,6 +2575,28 @@ async function main() {
2448
2575
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
2449
2576
  if (bot && bot._stop) bot._stop();
2450
2577
 
2578
+ // 1.5 (0.13 D1): expire open interactive questions {cancelled} BEFORE the
2579
+ // drain. With D1 the asking turn stays in flight for the whole wait, so a
2580
+ // deploy during a question would otherwise eat the entire 30s drain and
2581
+ // mark the inbound replay-pending mid-ask. Cancelling unblocks claude's
2582
+ // ask so the cycle can end inside the drain; the boot-replay re-ask is the
2583
+ // documented recovery path (design §3 D1 ask-wait semantics).
2584
+ try {
2585
+ const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
2586
+ for (const row of openQuestions) {
2587
+ // eslint-disable-next-line no-await-in-loop
2588
+ await questionHandlers.expireQuestion(row, {
2589
+ status: 'cancelled',
2590
+ message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
2591
+ }).catch(() => {});
2592
+ }
2593
+ if (openQuestions.length) {
2594
+ logEvent('shutdown-questions-cancelled', { count: openQuestions.length });
2595
+ }
2596
+ } catch (err) {
2597
+ console.error(`[shutdown] question expiry failed: ${err.message}`);
2598
+ }
2599
+
2451
2600
  // 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
2452
2601
  // SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
2453
2602
  // result events arrive; the dispatcher's .finally decrements.
@@ -2610,22 +2759,19 @@ async function main() {
2610
2759
  }
2611
2760
  const chatConfig = config.chats[row.chat_id];
2612
2761
  if (!chatConfig) { skipped += 1; continue; }
2613
- // Tag the reconstructed message so dispatchHandleMessage knows
2614
- // (a) to suppress the "Sorry I couldn't process" error reply on
2615
- // failure and (b) to flag handler-error events as replay.
2616
- reconstructed._isReplay = true;
2617
- // Pre-mark 'replay-attempted' so even if this attempt is killed
2618
- // mid-turn by yet another restart, the next boot won't replay it
2619
- // again. Replay is one-shot — handleMessage will overwrite to
2620
- // 'replied' on success, or the catch will overwrite to 'failed'.
2621
- // Worst case (polygram dies before either): row stays
2622
- // 'replay-attempted', getReplayCandidates skips it, no loop.
2623
- dbWrite(() => db.setInboundHandlerStatus({
2624
- chat_id: row.chat_id, msg_id: row.msg_id, status: 'replay-attempted',
2625
- }), 'set handler_status=replay-attempted');
2626
- const sessionKey = getSessionKey(row.chat_id, row.thread_id, chatConfig);
2627
- dispatchHandleMessage(sessionKey, row.chat_id, reconstructed, bot);
2628
- replayed += 1;
2762
+ // 0.13 D4: through the unified redelivery tail _isReplay tag (error
2763
+ // reply suppressed, not replay-eligible), 'replay-attempted' pre-mark
2764
+ // (one-shot: even if THIS attempt dies mid-turn, the next boot won't
2765
+ // loop), the D5 gate at tier 'redelivery' (abort/admin-shaped rows are
2766
+ // never auto-re-executed; a row whose chat lost its pairing since is
2767
+ // re-checked), a 👀 ack so the recovery is visible, then dispatch.
2768
+ // eslint-disable-next-line no-await-in-loop
2769
+ const r = await redeliverAsFreshTurn({
2770
+ chatId: row.chat_id, msg: reconstructed,
2771
+ source: 'boot-replay', preMark: 'replay-attempted',
2772
+ });
2773
+ if (r.ok) replayed += 1;
2774
+ else skipped += 1;
2629
2775
  }
2630
2776
  if (candidates.length > 0) {
2631
2777
  console.log(`[replay] ${replayed} turns re-dispatched, ${skipped} skipped (already replied or no chat config)`);