polygram 0.6.16 → 0.7.1

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/telegram.js CHANGED
@@ -20,7 +20,13 @@
20
20
  */
21
21
 
22
22
  const crypto = require('crypto');
23
- const { toTelegramMarkdown } = require('./telegram-format');
23
+ const {
24
+ toTelegramMarkdown,
25
+ isHtmlParseError,
26
+ isMessageNotModifiedError,
27
+ isRateLimitError,
28
+ getRetryAfterMs,
29
+ } = require('./telegram-format');
24
30
  const { isSafeToRetry, redactBotToken } = require('./net-errors');
25
31
 
26
32
  // Topic deletion race: a user can delete a forum topic while a turn is in
@@ -112,7 +118,30 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
112
118
  const text = deriveOutboundText(method, params, meta);
113
119
  const tracksMessage = !METHODS_WITHOUT_MSG.has(method);
114
120
 
121
+ // 0.7.0: snapshot the field that applyFormatting will convert and the
122
+ // current parse_mode state, so the HTML→plain fallback (if Telegram
123
+ // rejects the converted payload with `400 can't parse entities`) can
124
+ // restore the raw value and retry without parse_mode. Mirrors the
125
+ // applyFormatting decision (must run BEFORE applyFormatting mutates).
126
+ const willFormat = !meta.plainText
127
+ && FORMATTABLE_METHODS.has(method)
128
+ && params.parse_mode == null;
129
+ const formatField = willFormat ? (params.text ? 'text' : (params.caption ? 'caption' : null)) : null;
130
+ const rawFieldValue = formatField ? params[formatField] : null;
131
+
115
132
  applyFormatting(method, params, meta);
133
+ const formattingApplied = formatField && params.parse_mode === 'HTML';
134
+
135
+ // 0.7.0: per-bot/chat link-preview opt-out. When meta.linkPreview is
136
+ // explicitly false, suppress Telegram's auto-generated preview cards
137
+ // for any URL in the body (they clutter chats and add visual noise).
138
+ // Default (no flag set) preserves Telegram's native preview behavior
139
+ // — matches OpenClaw's account.config.linkPreview opt-out.
140
+ if (meta.linkPreview === false
141
+ && (method === 'sendMessage' || method === 'editMessageText')
142
+ && params.link_preview_options == null) {
143
+ params.link_preview_options = { is_disabled: true };
144
+ }
116
145
 
117
146
  // Capture which inbound this reply targets so the boot-replay dedupe
118
147
  // (`hasOutboundReplyTo`) can match outbound→inbound. Without this every
@@ -142,60 +171,151 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
142
171
  }
143
172
 
144
173
  let res;
145
- const attempt = async (p) => bot.api.raw[method](p);
146
- try {
174
+ const rawAttempt = async (p) => bot.api.raw[method](p);
175
+
176
+ // OpenClaw-style fallback layers, composing outermost-to-innermost:
177
+ //
178
+ // withThreadFallback (outer) — strips message_thread_id and
179
+ // retries on TOPIC_DELETED / "thread
180
+ // not found"
181
+ // withPreConnectRetry — single retry on transient pre-
182
+ // connect errors (DNS flap, TCP
183
+ // refused, ENETUNREACH); never
184
+ // retries post-connect errors that
185
+ // might have landed
186
+ // safeAttempt — sleeps `retry_after` and retries
187
+ // once on 429
188
+ // tryOnce (innermost) — handles two per-call recoveries:
189
+ // (a) MESSAGE_NOT_MODIFIED on
190
+ // editMessageText → synthetic
191
+ // success (streamer debounce often
192
+ // lands on no-op edits, Telegram
193
+ // returns 400 we don't want to
194
+ // propagate); (b) HTML parse error
195
+ // → retry as plain text with the
196
+ // raw pre-conversion field value
197
+ // restored
198
+ // rawAttempt — bot.api.raw[method](params)
199
+ //
200
+ // Each layer is a closure built per call; allocation cost is
201
+ // negligible vs. network RTT.
202
+ const RETRY_AFTER_CAP_MS = 60_000;
203
+ const tryOnce = async (p) => {
147
204
  try {
148
- res = await attempt(params);
205
+ return await rawAttempt(p);
206
+ } catch (err) {
207
+ if (method === 'editMessageText' && isMessageNotModifiedError(err)) {
208
+ try { db?.logEvent('telegram-edit-skip-not-modified', { chat_id: chatId, message_id: p.message_id }); }
209
+ catch {}
210
+ // Synthetic success — message_id known, date best-effort.
211
+ return { message_id: p.message_id, date: Math.floor(Date.now() / 1000), _notModified: true };
212
+ }
213
+ if (formattingApplied && isHtmlParseError(err)) {
214
+ logger.warn?.(`[telegram] ${method}: HTML parse error, retrying as plain text`);
215
+ try {
216
+ db?.logEvent('telegram-html-fallback', {
217
+ chat_id: chatId, method,
218
+ error: redactBotToken(err.message)?.slice(0, 200),
219
+ });
220
+ } catch {}
221
+ const plainParams = { ...p };
222
+ delete plainParams.parse_mode;
223
+ plainParams[formatField] = rawFieldValue;
224
+ try {
225
+ return await rawAttempt(plainParams);
226
+ } catch (plainErr) {
227
+ // 0.7.1: if the plain retry also fails, preserve BOTH errors
228
+ // in the message that propagates. Pre-fix, only `plainErr`
229
+ // bubbled up — operators investigating from markOutboundFailed
230
+ // saw e.g. "Forbidden: bot was kicked" and missed that the
231
+ // ORIGINAL failure was a markdown→HTML parse bug.
232
+ const origMsg = redactBotToken(err.message)?.slice(0, 200);
233
+ const wrapped = new Error(`plain-retry failed (after HTML parse error: ${origMsg}): ${plainErr.message}`);
234
+ if (plainErr.code) wrapped.code = plainErr.code;
235
+ if (plainErr.parameters) wrapped.parameters = plainErr.parameters;
236
+ throw wrapped;
237
+ }
238
+ }
239
+ throw err;
240
+ }
241
+ };
242
+ const safeAttempt = async (p) => {
243
+ try {
244
+ return await tryOnce(p);
245
+ } catch (err) {
246
+ if (!isRateLimitError(err)) throw err;
247
+ // Sleep retry_after then retry once. Cap at 60s so a misconfigured
248
+ // value can't park the call indefinitely.
249
+ const ms = Math.min(getRetryAfterMs(err) ?? 1000, RETRY_AFTER_CAP_MS);
250
+ logger.warn?.(`[telegram] ${method}: 429 rate-limited, retry-after ${ms}ms`);
251
+ try { db?.logEvent('telegram-rate-limit', { chat_id: chatId, method, retry_after_ms: ms }); }
252
+ catch {}
253
+ await sleep(ms);
254
+ return await tryOnce(p);
255
+ }
256
+ };
257
+
258
+ // 0.7.0: pre-connect retry layer (single retry on transient pre-conn
259
+ // errors). Composes inside thread-fallback so a re-attempt without
260
+ // thread_id also benefits from the retry.
261
+ const withPreConnectRetry = async (p) => {
262
+ try {
263
+ return await safeAttempt(p);
149
264
  } catch (err) {
150
265
  // Pre-connect errors (DNS flap, TCP refused, net unreach) never
151
- // reached Telegram, so retrying can't double-send. Retry ONCE after
152
- // a short delay before treating as fatal. Post-connect errors
153
- // (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message might have
154
- // landed server-side.
266
+ // reached Telegram, so retrying can't double-send. Retry ONCE
267
+ // after a short delay before treating as fatal. Post-connect
268
+ // errors (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message
269
+ // might have landed server-side.
155
270
  if (isSafeToRetry(err)) {
156
271
  try { db?.logEvent('telegram-retry', { chat_id: chatId, method, code: err.code, name: err.name }); }
157
272
  catch {}
158
273
  await sleep(PRE_CONNECT_RETRY_DELAY_MS);
159
- res = await attempt(params);
160
- } else {
161
- throw err;
274
+ return await safeAttempt(p);
162
275
  }
276
+ throw err;
163
277
  }
164
- } catch (err) {
165
- // Forum topic was deleted mid-turn — retry to chat root rather than
166
- // failing the whole reply. Only for methods that accept a thread id
167
- // (send*), and only once per call.
168
- if (isThreadNotFound(err) && params.message_thread_id != null) {
169
- const retryParams = { ...params };
278
+ };
279
+
280
+ // 0.7.0: thread-fallback layer (port of OpenClaw's
281
+ // withTelegramThreadFallback). On `message thread not found` /
282
+ // TOPIC_DELETED, retry once with thread_id stripped — the reply
283
+ // lands in the chat root instead of the deleted topic.
284
+ const withThreadFallback = async (p) => {
285
+ try {
286
+ return await withPreConnectRetry(p);
287
+ } catch (err) {
288
+ if (!isThreadNotFound(err) || p.message_thread_id == null) throw err;
289
+ const retryParams = { ...p };
170
290
  delete retryParams.message_thread_id;
291
+ logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
171
292
  try {
172
- logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
173
- res = await bot.api.raw[method](retryParams);
174
- try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(params.message_thread_id) }); }
293
+ const out = await withPreConnectRetry(retryParams);
294
+ try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(p.message_thread_id) }); }
175
295
  catch {}
296
+ return out;
176
297
  } catch (err2) {
177
- if (rowId != null && db) {
178
- // 0.6.14: redact bot tokens before persisting err.message —
179
- // some undici/network error shapes embed the request URL
180
- // (which carries `bot${TOKEN}`) into err.message.
181
- const safe2 = redactBotToken(err2.message);
182
- try { db.markOutboundFailed(rowId, safe2); }
183
- catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
184
- try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe2 }); }
185
- catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
186
- }
298
+ // Re-throw with the SECOND error — that's the actually-fatal
299
+ // one (the thread-fallback retry didn't save us).
187
300
  throw err2;
188
301
  }
189
- } else {
190
- if (rowId != null && db) {
191
- const safe = redactBotToken(err.message);
192
- try { db.markOutboundFailed(rowId, safe); }
193
- catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
194
- try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
195
- catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
196
- }
197
- throw err;
198
302
  }
303
+ };
304
+
305
+ try {
306
+ res = await withThreadFallback(params);
307
+ } catch (err) {
308
+ if (rowId != null && db) {
309
+ // 0.6.14: redact bot tokens before persisting err.message —
310
+ // some undici/network error shapes embed the request URL
311
+ // (which carries `bot${TOKEN}`) into err.message.
312
+ const safe = redactBotToken(err.message);
313
+ try { db.markOutboundFailed(rowId, safe); }
314
+ catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
315
+ try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
316
+ catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
317
+ }
318
+ throw err;
199
319
  }
200
320
 
201
321
  if (rowId != null && db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.6.16",
3
+ "version": "0.7.1",
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
@@ -32,6 +32,9 @@ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-sco
32
32
  const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
33
33
  const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
34
34
  const { createStreamer } = require('./lib/stream-reply');
35
+ const { chunkMarkdownText } = require('./lib/telegram-chunk');
36
+ const { deliverReplies } = require('./lib/deliver');
37
+ const { announce, shouldAnnounce } = require('./lib/announces');
35
38
  const { isAbortRequest } = require('./lib/abort-detector');
36
39
  const { startTyping } = require('./lib/typing-indicator');
37
40
  const { redactBotToken } = require('./lib/net-errors');
@@ -909,22 +912,6 @@ function parseResponse(text) {
909
912
  return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
910
913
  }
911
914
 
912
- // ─── Reply chunking ─────────────────────────────────────────────────
913
-
914
- function chunkText(text, maxLen = TG_MAX_LEN) {
915
- if (text.length <= maxLen) return [text];
916
- const chunks = [];
917
- let remaining = text;
918
- while (remaining.length > 0) {
919
- if (remaining.length <= maxLen) { chunks.push(remaining); break; }
920
- let splitAt = remaining.lastIndexOf('\n', maxLen);
921
- if (splitAt < maxLen * 0.3) splitAt = maxLen;
922
- chunks.push(remaining.slice(0, splitAt));
923
- remaining = remaining.slice(splitAt).replace(/^\n/, '');
924
- }
925
- return chunks;
926
- }
927
-
928
915
  // ─── Cron/IPC send ─────────────────────────────────────────────────
929
916
 
930
917
  // Allowlist of Telegram Bot API methods external callers (cron) may invoke.
@@ -1663,11 +1650,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1663
1650
  });
1664
1651
 
1665
1652
  const botCfg = config.bot || {};
1653
+ // 0.7.0: per-chat / per-bot link-preview opt-out (port from OpenClaw).
1654
+ // chat-level wins over bot-level. Default (both undefined) preserves
1655
+ // Telegram's native auto-preview behavior.
1656
+ const linkPreview = chatConfig.linkPreview != null
1657
+ ? chatConfig.linkPreview
1658
+ : botCfg.linkPreview;
1666
1659
  const outMetaBase = {
1667
1660
  source: 'bot-reply-stream',
1668
1661
  botName: BOT_NAME,
1669
1662
  model: chatConfig.model,
1670
1663
  effort: chatConfig.effort,
1664
+ ...(linkPreview === false ? { linkPreview: false } : {}),
1671
1665
  };
1672
1666
 
1673
1667
  // Streaming is unconditional as of 0.4.0 — matches OpenClaw's model and
@@ -1710,6 +1704,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1710
1704
  throw err;
1711
1705
  }
1712
1706
  },
1707
+ deleteMessage: async (messageId) => {
1708
+ // 0.7.0: route preview-discard through tg() so the action is
1709
+ // visible in the events log and gets the same retry/network
1710
+ // protections as other API calls. Failure is non-fatal: stale
1711
+ // bubble at chat top is acceptable when the actual reply has
1712
+ // already been redelivered as fresh chunks.
1713
+ try {
1714
+ await tg(bot, 'deleteMessage', {
1715
+ chat_id: chatId, message_id: messageId,
1716
+ }, { source: 'bot-reply-stream-discard', botName: BOT_NAME });
1717
+ } catch (err) {
1718
+ console.error(`[${label}] discard preview failed: ${err.message}`);
1719
+ throw err;
1720
+ }
1721
+ },
1713
1722
  minChars: botCfg.streamMinChars,
1714
1723
  throttleMs: botCfg.streamThrottleMs,
1715
1724
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
@@ -1739,12 +1748,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1739
1748
  // at which point we flip to THINKING (🤔).
1740
1749
  reactor.setState('QUEUED');
1741
1750
 
1742
- // Mark the inbound row terminal so boot replay doesn't pick it up again.
1743
- // Must fire down EVERY non-throwing exit path (early returns for error/
1744
- // NO_REPLY, streamed-reply early return, regular reply at end). 0.5.4
1745
- // hardened this earlier versions only marked at the bottom of try, so
1746
- // streamed replies (which return at line ~1477) left handler_status
1747
- // stuck at 'dispatched' forever, causing replay loops on every restart.
1751
+ // Mark the inbound row terminal so boot replay doesn't pick it up
1752
+ // again. Must fire down EVERY non-throwing exit path (early returns
1753
+ // for error / NO_REPLY, streamed-reply preview-becomes-final, the
1754
+ // discard+redeliver branch, regular reply at end). Earlier versions
1755
+ // only marked at the bottom of try, so streamed-reply early returns
1756
+ // left handler_status stuck at 'dispatched' forever and the next
1757
+ // boot replayed every long turn.
1748
1758
  const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
1749
1759
  chat_id: chatId, msg_id: msg.message_id, status: 'replied',
1750
1760
  }), 'set handler_status=replied');
@@ -1782,34 +1792,117 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1782
1792
  reactor.clear().catch(() => {});
1783
1793
  }
1784
1794
 
1785
- if (!result.text || result.text === 'NO_REPLY') { markReplied(); return; }
1795
+ // 0.7.0: empty-response fallback (port from OpenClaw —
1796
+ // EMPTY_RESPONSE_FALLBACK at reply-CdjLMJxg.js:40323). When
1797
+ // Claude finishes WITHOUT producing any text (e.g. only tool
1798
+ // calls, or aborted before writing the assistant message), send
1799
+ // a placeholder so the user doesn't see silence with no reaction.
1800
+ // NO_REPLY is an explicit "stay silent" signal from the agent —
1801
+ // those still markReplied silently.
1802
+ if (result.text === 'NO_REPLY') { markReplied(); return; }
1803
+ if (!result.text) {
1804
+ // 0.7.1: if the fallback send itself fails, throw rather than
1805
+ // silently markReplied — the user gets nothing AND the inbound
1806
+ // is marked replied so boot replay won't redispatch. Same
1807
+ // anti-pattern that caused msg-10794. Promote to a thrown error
1808
+ // so dispatchHandleMessage's catch branches correctly:
1809
+ // shutdown → 'replay-pending' (boot replay retries)
1810
+ // runtime → 'failed' + user-visible apology via errorReplyText
1811
+ try {
1812
+ await tg(bot, 'sendMessage', {
1813
+ chat_id: chatId,
1814
+ text: 'No response generated. Please try again.',
1815
+ ...(threadId && { message_thread_id: threadId }),
1816
+ reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
1817
+ }, { ...outMetaBase, source: 'empty-response-fallback' });
1818
+ } catch (err) {
1819
+ reactor.setState('ERROR');
1820
+ logEvent('telegram-empty-response-fallback-failed', {
1821
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
1822
+ error: err.message?.slice(0, 200),
1823
+ });
1824
+ throw new Error(`empty-response fallback send failed: ${err.message}`);
1825
+ }
1826
+ logEvent('telegram-empty-response-fallback', {
1827
+ chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
1828
+ });
1829
+ markReplied();
1830
+ return;
1831
+ }
1786
1832
 
1787
1833
  const parsed = parseResponse(result.text);
1788
1834
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1789
1835
 
1790
- // Streamed text path: finalise the live-edit and, if the full response
1791
- // overflows Telegram's 4096 cap, send remainder as follow-up chunks.
1836
+ // OpenClaw's preview-becomes-final flow:
1837
+ //
1838
+ // 1. flushDraft() — drain any pending throttled edit so the
1839
+ // bubble's visible state is up-to-date before deciding.
1840
+ // 2. finalize(parsed.text) — try to bring the bubble to the
1841
+ // final body. Returns rich result describing whether the
1842
+ // preview can stand as the final reply.
1843
+ // 3a. finalEditOk:true → preview IS final, done.
1844
+ // 3b. overflow OR !finalEditOk → discard preview, redeliver
1845
+ // via deliverReplies(chunkMarkdownText(...)). The bubble
1846
+ // couldn't render the full body (size or parse error), so
1847
+ // we delete it cleanly and send the proper chunks fresh at
1848
+ // chat bottom — no content lost, no stranded bubble.
1792
1849
  if (parsed.text) {
1850
+ await streamer.flushDraft();
1793
1851
  const fin = await streamer.finalize(parsed.text);
1794
1852
  if (fin.streamed) {
1795
- if (parsed.text.length > TG_MAX_LEN) {
1796
- const rest = parsed.text.slice(TG_MAX_LEN - 3);
1797
- for (const chunk of chunkText(rest)) {
1798
- try {
1799
- await tg(bot, 'sendMessage', {
1800
- chat_id: chatId, text: chunk,
1801
- ...(threadId && { message_thread_id: threadId }),
1802
- }, outMeta);
1803
- } catch (err) {
1804
- console.error(`[${label}] overflow sendMessage failed: ${err.message}`);
1805
- }
1853
+ if (fin.finalEditOk) {
1854
+ // Preview was successfully edited to the final text.
1855
+ // No follow-up messages needed.
1856
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1857
+ markReplied();
1858
+ return;
1859
+ }
1860
+ // Preview can't hold the final body (overflow OR last edit
1861
+ // failed even after our HTML→plain fallback). Delete it and
1862
+ // send the body as proper chunks.
1863
+ try { await streamer.discard(); }
1864
+ catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
1865
+ const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1866
+ const r = await deliverReplies({
1867
+ bot,
1868
+ send: (b, method, params, m) => tg(b, method, params, m),
1869
+ chatId,
1870
+ threadId,
1871
+ chunks,
1872
+ replyToMessageId: msg.message_id,
1873
+ meta: outMeta,
1874
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
1875
+ });
1876
+ const reason = fin.overflow ? 'overflow' : 'edit-failed';
1877
+ logEvent('telegram-stream-redeliver', {
1878
+ chat_id: chatId, msg_id: msg.message_id,
1879
+ reason, chunks: chunks.length,
1880
+ delivered: r.sent.length, failed: r.failed.length,
1881
+ bot: BOT_NAME,
1882
+ });
1883
+ // 0.7.1: surface partial-failure to the user. Without this,
1884
+ // a chunk-3-of-5 failure leaves a coherent-looking reply with
1885
+ // a silent gap (the user reads chunks 1, 2, 4, 5 unaware
1886
+ // that chunk 3 was dropped). Append a warning + flip the
1887
+ // reactor to ERROR so something visible signals "look here".
1888
+ if (r.failed.length > 0) {
1889
+ reactor.setState('ERROR');
1890
+ try {
1891
+ await tg(bot, 'sendMessage', {
1892
+ chat_id: chatId,
1893
+ text: `⚠️ ${r.failed.length} of ${chunks.length} message parts failed to deliver. The reply may be incomplete — please retry.`,
1894
+ ...(threadId && { message_thread_id: threadId }),
1895
+ }, { ...outMetaBase, source: 'partial-delivery-warning' });
1896
+ } catch (warnErr) {
1897
+ console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
1806
1898
  }
1807
1899
  }
1808
- console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1900
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1809
1901
  markReplied();
1810
1902
  return;
1811
1903
  }
1812
- // Not streamed (response too short)fall through to normal path.
1904
+ // Not streamed (response too short — never crossed minChars).
1905
+ // Fall through to the normal send path below.
1813
1906
  }
1814
1907
 
1815
1908
  if (parsed.reaction) {
@@ -1829,19 +1922,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1829
1922
  console.error(`[${label}] sendSticker failed: ${err.message}`);
1830
1923
  });
1831
1924
  } else if (parsed.text) {
1832
- const chunks = chunkText(parsed.text);
1833
- for (let i = 0; i < chunks.length; i++) {
1834
- const params = {
1835
- chat_id: chatId, text: chunks[i],
1836
- ...(threadId && { message_thread_id: threadId }),
1837
- };
1838
- if (i === 0) params.reply_parameters = { message_id: msg.message_id };
1839
- try {
1840
- await tg(bot, 'sendMessage', params, outMeta);
1841
- } catch (err) {
1842
- console.error(`[${label}] sendMessage failed (chunk ${i + 1}/${chunks.length}): ${err.message}`);
1843
- }
1844
- }
1925
+ // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
1926
+ // The old chunkText was newline/byte-only; chunkMarkdownText also
1927
+ // respects code-fence boundaries (closes + reopens across chunks).
1928
+ const chunks = chunkMarkdownText(parsed.text, TG_MAX_LEN);
1929
+ await deliverReplies({
1930
+ bot,
1931
+ send: (b, method, params, m) => tg(b, method, params, m),
1932
+ chatId,
1933
+ threadId,
1934
+ chunks,
1935
+ replyToMessageId: msg.message_id,
1936
+ meta: outMeta,
1937
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
1938
+ });
1845
1939
  }
1846
1940
 
1847
1941
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
@@ -2480,6 +2574,38 @@ async function main() {
2480
2574
  const head = entry.pendingQueue?.[0];
2481
2575
  const r = head?.context?.reactor;
2482
2576
  if (r) r.setState(classifyToolName(toolName));
2577
+ // 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
2578
+ // the Task tool to spawn a subagent, post a brief informational
2579
+ // message to the chat so the user knows a heavier turn is in
2580
+ // progress. Off by default (per-bot or per-chat
2581
+ // `announceSubagents: true` opts in). Per-chat debounce 30s
2582
+ // prevents announce-storms in tool-heavy turns.
2583
+ const chatCfg = config.chats[entry.chatId] || {};
2584
+ const optIn = chatCfg.announceSubagents != null
2585
+ ? chatCfg.announceSubagents
2586
+ : config.bot?.announceSubagents;
2587
+ if (toolName === 'Task' && optIn === true) {
2588
+ if (shouldAnnounce(entry.chatId)) {
2589
+ announce({
2590
+ send: (b, method, params, m) => tg(b, method, params, m),
2591
+ bot, chatId: entry.chatId,
2592
+ threadId: head?.context?.threadId ?? null,
2593
+ text: '🤖 Spawning subagent…',
2594
+ meta: { botName: BOT_NAME, source: 'subagent-announce' },
2595
+ logger: { error: (m) => console.error(`[${entry.label}] ${m}`) },
2596
+ });
2597
+ }
2598
+ }
2599
+ },
2600
+ // 0.7.0 (Phase F): each new top-level assistant message gets its
2601
+ // own bubble. When Claude emits text, then tool_use, then more
2602
+ // text in a NEW assistant message (typical of tool-heavy turns),
2603
+ // the previous bubble's content stays visible as a "thinking out
2604
+ // loud" intermediate; the new message starts fresh below.
2605
+ onAssistantMessageStart: (sessionKey, entry) => {
2606
+ const head = entry.pendingQueue?.[0];
2607
+ const s = head?.context?.streamer;
2608
+ if (s) s.forceNewMessage();
2483
2609
  },
2484
2610
  // Fires after a graceful /model or /effort drain has actually
2485
2611
  // swapped to the new settings. Post a confirmation back to the