polygram 0.12.0-rc.33 → 0.12.0-rc.35

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.
@@ -78,9 +78,24 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
78
78
  const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
79
79
  const ver = MODEL_VERSIONS_DESC[model] || model;
80
80
  const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
81
+ // Running vs configured: cli can't hot-swap model/effort, so a /model or
82
+ // /effort change is PENDING until the session reloads (on the next message).
83
+ // Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
84
+ // vs the configured one — so the card never claims a model the session
85
+ // isn't actually running (the "says opus, runs sonnet" confusion). SDK
86
+ // applies live (its proc value tracks config) so no drift line ever shows.
87
+ const proc = alive ? pm.get(sessionKey) : null;
88
+ const runModel = proc && proc.model;
89
+ const runEffort = proc && proc.effort;
90
+ const modelLine = (runModel && runModel !== model)
91
+ ? `Model: ${runModel} (running) → ${model} (pending — applies on your next message)`
92
+ : `Model: ${model} (${ver})`;
93
+ const effortLine = (runEffort && runEffort !== effort)
94
+ ? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
95
+ : `Effort: ${effort}`;
81
96
  const head =
82
- `Model: ${model} (${ver})\n` +
83
- `Effort: ${effort}\n` +
97
+ `${modelLine}\n` +
98
+ `${effortLine}\n` +
84
99
  `Agent: ${agent}\n` +
85
100
  `Process: ${alive ? 'warm' : 'cold'}\n` +
86
101
  `Session: ${sess}`;
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Post-turn edit re-delivery (0.12.0). When the user edits a Telegram message AFTER claude's turn
5
+ * has finished, re-dispatch the edited message as a NEW turn so claude acts on the change — the
6
+ * "an edit is just a message" model. The mid-turn case (turn still in flight) stays with the
7
+ * existing injector (lib/handlers/edit-correction.js); this is the post-turn path.
8
+ *
9
+ * Spec: docs/0.12.0-edit-redelivery-spec.md (twice-reviewed). Key correctness points the review
10
+ * surfaced:
11
+ * - Convey the change via reply_to carrying the OLD text (the caller captures it before
12
+ * recordInbound overwrites the row) — replying to the live row would quote the NEW text, so
13
+ * claude would see no before/after and couldn't tell it's an edit.
14
+ * - GATE on the REAL edited message, NOT the synthetic: a self-reply_to trips shouldHandle's
15
+ * `repliesToOtherUser` and drops paired users in mention-gated groups.
16
+ * - The synthetic is `_isReplay`-tagged → no new editable row, never replay-eligible, error
17
+ * reply suppressed.
18
+ * - A re-edit while our re-run is in flight FOLDS via inject (the interlock) rather than
19
+ * starting a second turn.
20
+ *
21
+ * @param {object} deps
22
+ * @param {object} deps.pm ProcessManager (get(sessionKey).inFlight, injectUserMessage)
23
+ * @param {object} deps.config
24
+ * @param {Function} deps.getSessionKey (chatId, threadId, chatConfig) => sessionKey
25
+ * @param {Function} deps.shouldHandle (msg, chatConfig, botUsername) => boolean — the real gate
26
+ * @param {Function} deps.dispatchHandleMessage (sessionKey, chatId, msg, bot) => void
27
+ * @param {object} deps.bot
28
+ * @param {Function} [deps.react] (chatId, msgId) => void|Promise — on-edit acknowledgment
29
+ * @param {Function} [deps.logEvent]
30
+ * @param {object} [deps.logger]
31
+ * @returns {(editedMsg, oldText, botUsername, mentionRe?) => boolean} true when a fresh turn was
32
+ * dispatched. botUsername / mentionRe are passed at CALL time (not construction): they are
33
+ * resolved asynchronously via getMe and live in the createBot scope, so capturing them in the
34
+ * factory (built in main()) would both be out of scope and freeze the empty initial values.
35
+ */
36
+ function createEditRedelivery({
37
+ pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
38
+ react, logEvent = () => {}, logger = console,
39
+ } = {}) {
40
+ // botUsername / mentionRe arrive at CALL time — see @returns. Constructing with them threw
41
+ // `ReferenceError: mentionRe is not defined` at boot (rc.34): the factory runs in main() where
42
+ // those createBot locals don't exist. The edited_message handler passes the live values.
43
+ return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
44
+ try {
45
+ if (!editedMsg?.chat) return false;
46
+ const chatId = editedMsg.chat.id.toString();
47
+ const chatConfig = config.chats[chatId];
48
+ if (!chatConfig) return false;
49
+
50
+ // Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
51
+ const optOut = chatConfig.editCorrection != null
52
+ ? chatConfig.editCorrection === false
53
+ : config.bot?.editCorrection === false;
54
+ if (optOut) return false;
55
+
56
+ const newText = editedMsg.text || editedMsg.caption || '';
57
+ if (!newText) return false; // blanked / media-only → nothing to act on
58
+ // Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
59
+ // caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
60
+ if (oldText != null && oldText === newText) return false;
61
+
62
+ const threadId = editedMsg.message_thread_id?.toString() || null;
63
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
64
+
65
+ // Interlock: a turn is in flight (most likely OUR own re-run, whose _isReplay row reads
66
+ // not-live so the mid-turn injector skipped it and fell through to here). Fold the re-edit
67
+ // via inject instead of spawning a SECOND re-dispatch turn for the same message.
68
+ const proc = pm?.get?.(sessionKey);
69
+ if (proc?.inFlight) {
70
+ pm.injectUserMessage?.(sessionKey, {
71
+ content: `[edit] I edited my message again — it now reads: ${newText}`,
72
+ priority: 'next',
73
+ msgId: editedMsg.message_id,
74
+ });
75
+ logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
76
+ return false;
77
+ }
78
+
79
+ // GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
80
+ // NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
81
+ // paired user editing an un-mentioned message in a mention-gated group.
82
+ if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
83
+
84
+ // Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
85
+ // claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
86
+ try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
87
+
88
+ // Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
89
+ // reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
90
+ // renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
91
+ const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
92
+ const synthetic = {
93
+ chat: editedMsg.chat,
94
+ message_id: editedMsg.message_id,
95
+ from: editedMsg.from,
96
+ text: cleanNew,
97
+ date: editedMsg.date,
98
+ ...(threadId && { message_thread_id: Number(threadId) }),
99
+ reply_to_message: {
100
+ message_id: editedMsg.message_id,
101
+ from: editedMsg.from,
102
+ text: oldText || '',
103
+ date: editedMsg.date,
104
+ },
105
+ _isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
106
+ };
107
+ dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
108
+ logEvent('edit-redelivered', {
109
+ chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
110
+ old_len: (oldText || '').length, new_len: newText.length,
111
+ });
112
+ return true;
113
+ } catch (e) {
114
+ // Never throw out of the edited_message handler.
115
+ logger.error?.(`[edit-redelivery] ${e.message}`);
116
+ return false;
117
+ }
118
+ };
119
+ }
120
+
121
+ module.exports = { createEditRedelivery };
@@ -186,6 +186,30 @@ function createSlashCommands({
186
186
  return { anyActive: !applied };
187
187
  };
188
188
 
189
+ // cli can't hot-swap model/effort live (they are spawn-time --model /
190
+ // --effort flags). The change is persisted to chatConfig and applies when
191
+ // the session next (re)spawns — getOrSpawn's reload-on-drift makes that the
192
+ // user's NEXT message, conversation preserved (--resume). So give an honest
193
+ // suffix per backend instead of the misleading "I'll switch when I finish".
194
+ // (Pre-fix this checked backendName === 'channels', but 0.12.0 renamed the
195
+ // cli backend 'channels' → 'cli', so it never fired and every cli user got
196
+ // the wrong message — Review F#10 regression.)
197
+ const cliAwareSuffix = (anyActive) => {
198
+ const liveBackend = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
199
+ if (liveBackend === 'cli') {
200
+ const proc = typeof pm.get === 'function' ? pm.get(sessionKey) : null;
201
+ return proc && proc.inFlight
202
+ ? ' — applies after this turn (conversation kept)'
203
+ : ' — applies on your next message (conversation kept)';
204
+ }
205
+ // cli but cold (no live proc): the next message cold-spawns with the new flag.
206
+ if (!liveBackend && (chatConfig.pm || config.bot?.pm) === 'cli') {
207
+ return ' — applies on your next message';
208
+ }
209
+ // SDK: applied live (anyActive false) or no live session to push into.
210
+ return anyActive ? ' — I\'ll switch when I finish' : '';
211
+ };
212
+
189
213
  // /model X
190
214
  if (botAllowsCommands && text.startsWith('/model ')) {
191
215
  const newModel = text.slice(7).trim();
@@ -199,18 +223,7 @@ function createSlashCommands({
199
223
  }), 'log model change');
200
224
  const { anyActive } = await applyConfigChange('model', newModel);
201
225
  const ver = (modelVersionsDesc && modelVersionsDesc[newModel]) || newModel;
202
- // Review F#10: channels backend can't apply model/effort changes
203
- // live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
204
- // pm.setModel returns false → `anyActive` is true → user saw the
205
- // misleading "I'll switch when I finish" message. Now we detect
206
- // the channels backend explicitly and give an honest answer:
207
- // settings are persisted to chatConfig and take effect on the next
208
- // /reset or /new (channels lacks an in-place re-init path).
209
- const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
210
- const suffix = backendName === 'channels'
211
- ? ` — applies on next /reset (channels)`
212
- : (anyActive ? ` — I'll switch when I finish` : '');
213
- await sendReply(`Model → ${newModel} (${ver})${suffix}`);
226
+ await sendReply(`Model ${newModel} (${ver})${cliAwareSuffix(anyActive)}`);
214
227
  } else {
215
228
  await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
216
229
  }
@@ -229,18 +242,7 @@ function createSlashCommands({
229
242
  user: cmdUser, user_id: cmdUserId, source: 'command',
230
243
  }), 'log effort change');
231
244
  const { anyActive } = await applyConfigChange('effort', newEffort);
232
- // Review F#10: channels backend can't apply model/effort changes
233
- // live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
234
- // pm.setModel returns false → `anyActive` is true → user saw the
235
- // misleading "I'll switch when I finish" message. Now we detect
236
- // the channels backend explicitly and give an honest answer:
237
- // settings are persisted to chatConfig and take effect on the next
238
- // /reset or /new (channels lacks an in-place re-init path).
239
- const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
240
- const suffix = backendName === 'channels'
241
- ? ` — applies on next /reset (channels)`
242
- : (anyActive ? ` — I'll switch when I finish` : '');
243
- await sendReply(`Effort → ${newEffort}${suffix}`);
245
+ await sendReply(`Effort ${newEffort}${cliAwareSuffix(anyActive)}`);
244
246
  } else {
245
247
  await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
246
248
  }
@@ -545,9 +545,15 @@ class CliProcess extends Process {
545
545
  // after this.
546
546
  const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
547
547
  const agent = topicConfig?.agent || opts.chatConfig?.agent || opts.agent;
548
- const model = topicConfig?.model || opts.chatConfig?.model || opts.model;
549
- const effort = topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
548
+ const model = this._resolveModel(opts);
549
+ const effort = this._resolveEffort(opts);
550
550
  const resolvedCwd = topicConfig?.cwd || opts.chatConfig?.cwd || opts.cwd;
551
+ // Record the spawn-time model/effort. cli has no live model/effort swap
552
+ // (they are spawn-time --model / --effort flags), so getOrSpawn detects a
553
+ // /model or /effort drift against these and reloads — --resume preserves
554
+ // the conversation, the new flag takes effect. See wouldReloadFor.
555
+ this.model = model;
556
+ this.effort = effort;
551
557
 
552
558
  // File-send outbound cap (bot → user). Backend-derived (cloud 50MB vs
553
559
  // local Bot API server 2GB via opts.localApi) with per-topic/chat
@@ -1744,6 +1750,38 @@ class CliProcess extends Process {
1744
1750
  return this._bgWorkSince !== null;
1745
1751
  }
1746
1752
 
1753
+ /**
1754
+ * Resolve the model / effort for a spawn context using the topic→chat→
1755
+ * fallback precedence (mirrors the spawn path). Single source of truth shared
1756
+ * by start() (which records this.model / this.effort) and wouldReloadFor()
1757
+ * (which compares the current config to those spawn-time values).
1758
+ */
1759
+ _resolveModel(opts) {
1760
+ const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
1761
+ return topicConfig?.model || opts.chatConfig?.model || opts.model;
1762
+ }
1763
+
1764
+ _resolveEffort(opts) {
1765
+ const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
1766
+ return topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
1767
+ }
1768
+
1769
+ /**
1770
+ * getOrSpawn calls this before reusing a warm proc. cli can't hot-swap model
1771
+ * or effort (spawn-time flags), so when the resolved config has drifted from
1772
+ * what we spawned with AND we are idle, the proc must be killed + cold-
1773
+ * respawned (--resume keeps the conversation; the new --model / --effort takes
1774
+ * effect). In-flight → false: fold the message into the running turn; the
1775
+ * drift reloads on the next idle dispatch. SDK procs apply model live and do
1776
+ * NOT implement this method, so process-manager only reloads when it exists.
1777
+ * @returns {boolean}
1778
+ */
1779
+ wouldReloadFor(spawnContext) {
1780
+ if (this.inFlight || this.closed) return false;
1781
+ return this._resolveModel(spawnContext) !== this.model
1782
+ || this._resolveEffort(spawnContext) !== this.effort;
1783
+ }
1784
+
1747
1785
  /**
1748
1786
  * Stall-watchdog for detached background work (0.12.0 background-work
1749
1787
  * lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
@@ -2629,8 +2667,18 @@ class CliProcess extends Process {
2629
2667
  */
2630
2668
  writeQuestionAnswer(toolCallId, result) {
2631
2669
  this._openQuestions.delete(toolCallId);
2632
- if (this._openQuestions.size === 0) this._stopQuestionKeepAlive();
2633
- return this._writeToBridge({ kind: 'question_answer', tool_call_id: toolCallId, result: result ?? {} });
2670
+ const noneLeft = this._openQuestions.size === 0;
2671
+ if (noneLeft) this._stopQuestionKeepAlive();
2672
+ const wrote = this._writeToBridge({ kind: 'question_answer', tool_call_id: toolCallId, result: result ?? {} });
2673
+ // Re-light progress: claude is about to resume working on the answer. The per-turn reactor
2674
+ // cleared when claude posted its reply + asked, and no tool hooks fired during the wait, so
2675
+ // it stayed cleared — the post-answer work was invisible ("why don't I see it working after
2676
+ // submit?", hire topic 2026-06-09). On a REAL answer (cancelled/timeout END the turn → let
2677
+ // the normal teardown clear), signal polygram to re-arm the turn's working reaction.
2678
+ if (noneLeft && result && !result.cancelled && !result.timedout) {
2679
+ this.emit('question-resumed');
2680
+ }
2681
+ return wrote;
2634
2682
  }
2635
2683
 
2636
2684
  _startQuestionKeepAlive() {
@@ -58,6 +58,11 @@ const CALLBACK_TO_EVENT = {
58
58
  // the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
59
59
  // the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
60
60
  onQuestionAsked: 'question-asked',
61
+ // 0.12.0 question-progress-resume: CliProcess emits 'question-resumed' (no payload) when a
62
+ // blocking `ask` resolves with a real answer and the turn resumes working. The callback
63
+ // re-arms the per-turn reactor (it cleared during the wait, no hooks re-lit it). See
64
+ // docs/0.12.0-question-resume-progress-spec.md.
65
+ onQuestionResumed: 'question-resumed',
61
66
  onQueueDrop: 'queue-drop',
62
67
  onThinking: 'thinking',
63
68
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -237,7 +242,23 @@ class ProcessManager {
237
242
  // caller receives a proc whose start() has fully resolved.
238
243
  const pendingStart = this._starting.get(sessionKey);
239
244
  if (pendingStart) await pendingStart;
240
- return existing;
245
+ // Reload-on-drift (cli): a warm cli proc can't hot-swap model/effort
246
+ // (spawn-time flags). If the resolved config has drifted and the proc is
247
+ // idle, kill it (preserves session_id) and fall through to a cold respawn
248
+ // → --resume keeps the conversation, the new --model/--effort takes
249
+ // effect. In-flight cli procs and SDK procs (no wouldReloadFor — they
250
+ // apply model live) are reused unchanged.
251
+ if (typeof existing.wouldReloadFor === 'function' && existing.wouldReloadFor(spawnContext)) {
252
+ this._logEvent('cli-config-reload', {
253
+ sessionKey,
254
+ from_model: existing.model,
255
+ from_effort: existing.effort,
256
+ });
257
+ await this.kill(sessionKey, 'config-reload');
258
+ // fall through to the cold-spawn path below — respawns with --resume
259
+ } else {
260
+ return existing;
261
+ }
241
262
  }
242
263
 
243
264
  // Provisional new-process cost — ask the factory but don't start yet.
@@ -62,14 +62,18 @@ function renderCurrent(state, callbackBase) {
62
62
  lines.push(String(q.question ?? ''));
63
63
  lines.push('');
64
64
  opts.forEach((o, i) => {
65
- const mark = multi && state.toggles[i] ? '☑️ ' : ' ';
65
+ // Multi-select renders as a checklist (☐ unchecked / ☑️ checked) so it's legible as a
66
+ // tap-to-toggle-then-Submit control, NOT a single-select button. Single-select keeps `•`.
67
+ const mark = multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : '• ';
66
68
  lines.push(`${mark}${truncLabel(o.label)}${o.description ? ` — ${o.description}` : ''}`);
67
69
  if (o.preview) lines.push(String(o.preview).slice(0, MAX_PREVIEW));
68
70
  });
69
- if (multi) lines.push('\nTap options to toggle, then Submit.');
71
+ if (multi) lines.push('\nTap to check/uncheck — pick one or more, then Submit.');
70
72
 
71
73
  const rows = opts.map((o, i) => ([{
72
- text: `${multi && state.toggles[i] ? '☑️ ' : ''}${truncLabel(o.label)}`,
74
+ // The checkbox glyph on the BUTTON is the load-bearing affordance: an unchecked option
75
+ // must show ☐ (not a bare label) or it reads like a single-select tap-to-submit button.
76
+ text: `${multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : ''}${truncLabel(o.label)}`,
73
77
  callback_data: `${callbackBase}:opt:${i}`,
74
78
  }]));
75
79
  if (multi) {
@@ -352,6 +352,24 @@ function createSdkCallbacks({
352
352
  // Telegram inline keyboard via the question handler (late-bound from polygram).
353
353
  // payload: {chatId, threadId, turnId, toolCallId, questions}. The handler
354
354
  // itself is anti-hang (answers claude {cancelled} on any send failure).
355
+ // 0.12 interactive questions: the blocking `ask` resolved → the turn is resuming work. The
356
+ // per-turn reactor cleared when claude posted its reply + asked, and no hooks fired during
357
+ // the wait, so it never came back — the post-answer work showed no progress ("why don't I
358
+ // see it working after submit?"). Re-arm the head pending's reactor to THINKING. setState is
359
+ // a safe no-op if the reactor was stopped; typing is unaffected (its per-turn loop runs to
360
+ // turn-end). Guarded — never throws on a torn-down turn.
361
+ onQuestionResumed: (sessionKey, entry) => {
362
+ try {
363
+ const r = entry?.pendingQueue?.[0]?.context?.reactor;
364
+ if (r && typeof r.setState === 'function') {
365
+ r.setState('THINKING');
366
+ logEvent('question-resumed', { chat_id: getChatIdFromKey(sessionKey), session_key: sessionKey });
367
+ }
368
+ } catch (err) {
369
+ logger.error?.(`[${botName}] onQuestionResumed failed: ${err.message}`);
370
+ }
371
+ },
372
+
355
373
  onQuestionAsked: async (sessionKey, payload) => {
356
374
  try {
357
375
  if (typeof renderQuestion !== 'function') return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.33",
3
+ "version": "0.12.0-rc.35",
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
@@ -66,6 +66,7 @@ const { createHandleConfigCallback } = require('./lib/handlers/config-callback')
66
66
  const { createHandleAbort } = require('./lib/handlers/abort');
67
67
  const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
68
68
  const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
69
+ const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
69
70
  const { createSlashCommands } = require('./lib/handlers/slash-commands');
70
71
  const { createApprovals } = require('./lib/handlers/approvals');
71
72
  const { canonicalizeToolInput } = require('./lib/canonical-json');
@@ -643,6 +644,7 @@ let handleAbortIfRequested = null;
643
644
  let autosteer = null;
644
645
  let dispatchSlashCommand = null;
645
646
  let maybeInjectEditCorrection = null;
647
+ let maybePostTurnEdit = null;
646
648
 
647
649
  // rc.20: approvalCardText + safeParse moved to lib/approvals/ui.js.
648
650
  // 0.9.0 commit 29: makeCanUseTool / handleApprovalCallback /
@@ -2053,6 +2055,10 @@ function createBot(token) {
2053
2055
  }
2054
2056
  const chatId = ctx.editedMessage.chat.id.toString();
2055
2057
  if (!knownChat(chatId)) return;
2058
+ // 0.12.0 spec §3 (HARD): read the OLD text BEFORE recordInbound overwrites the row — the
2059
+ // post-turn changed-guard compares it, and the re-dispatch quotes it in reply_to so claude sees
2060
+ // the before/after. Reading after recordInbound would yield the new text (useless).
2061
+ const oldText = db.getMessage(chatId, ctx.editedMessage.message_id)?.text ?? null;
2056
2062
  recordInbound(ctx.editedMessage);
2057
2063
  logEvent('message-edited', {
2058
2064
  chat_id: chatId,
@@ -2061,16 +2067,15 @@ function createBot(token) {
2061
2067
  });
2062
2068
  console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
2063
2069
 
2064
- // 0.9.0: typo-correction injection. If the SDK still has this turn
2065
- // in flight (handler_status in dispatched/processing AND
2066
- // pm.get(sk).inFlight), inject a `[edit] corrected: <NEW>` note
2067
- // via the same channel autosteer uses. Lets users fix typos
2068
- // mid-turn without /stop + resend. No-op when the turn already
2069
- // completed.
2070
+ // Mid-turn (turn still in flight) → fold into the running turn via the 0.9.0 injector. Post-turn
2071
+ // (idle) OR the injector no-ops because the turn just settled at the boundary — → re-dispatch
2072
+ // the edited message as a NEW turn (0.12.0 edit re-delivery). `injected===false` gives the
2073
+ // self-contained boundary fall-through.
2070
2074
  try {
2071
- maybeInjectEditCorrection?.(ctx.editedMessage);
2075
+ const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
2076
+ if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText, botUsername, mentionRe);
2072
2077
  } catch (err) {
2073
- console.error(`[${BOT_NAME}] edit-correction injector error: ${err.message}`);
2078
+ console.error(`[${BOT_NAME}] edit handler error: ${err.message}`);
2074
2079
  }
2075
2080
  });
2076
2081
 
@@ -2505,6 +2510,17 @@ async function main() {
2505
2510
  getIsShuttingDown: () => isShuttingDown,
2506
2511
  logger: console,
2507
2512
  }));
2513
+ // 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
2514
+ // An edit while a turn is in flight folds via maybeInjectEditCorrection; an edit after the turn
2515
+ // (or when the injector no-ops at the boundary) re-dispatches as a new turn. The on-edit 👀 is a
2516
+ // pre-turn ack for the cold-spawn gap; the synthetic turn's own reactor then takes over the msg.
2517
+ maybePostTurnEdit = createEditRedelivery({
2518
+ pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
2519
+ react: (chatId, msgId) => applyReactionToMessages({
2520
+ tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
2521
+ }).catch(() => {}),
2522
+ logEvent, logger: console,
2523
+ });
2508
2524
  ({ pollBot, startPollWatchdog } = createPollLoop({
2509
2525
  db, dbWrite, config, botName: BOT_NAME,
2510
2526
  isWellFormedMessage, getTopicName,