polygram 0.8.0-rc.37 → 0.8.0-rc.39

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.37",
4
+ "version": "0.8.0-rc.39",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -120,7 +120,19 @@ function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = n
120
120
  } catch { /* logger errors must not break the hook */ }
121
121
  }
122
122
  if (typeof onDrained === 'function') {
123
- try { onDrained(sessionKey, drained.length); }
123
+ // rc.38: async-safe. onDrained may return a Promise (it does
124
+ // today — clearAutosteeredReactions is async). A bare
125
+ // synchronous try/catch only catches throws, not rejections;
126
+ // an unhandled rejection escaping the hook would land on the
127
+ // process-level handler as misleading noise. Detect a
128
+ // thenable and attach .catch so async failures are logged at
129
+ // the same site, not as out-of-band unhandledRejection.
130
+ try {
131
+ const r = onDrained(sessionKey, drained.length);
132
+ if (r && typeof r.then === 'function') {
133
+ r.catch((err) => logger?.error?.(`[${sessionKey}] onDrained async: ${err?.message || err}`));
134
+ }
135
+ }
124
136
  catch (err) { logger?.error?.(`[${sessionKey}] onDrained: ${err?.message || err}`); }
125
137
  }
126
138
  return {
@@ -3,7 +3,8 @@
3
3
  * - sticker (single emoji that maps to a sticker, OR literal
4
4
  * `[sticker:NAME]` mimic — see below)
5
5
  * - reaction (single emoji not mapped to a sticker)
6
- * - text (everything else)
6
+ * - text (everything else, with inline `[sticker:NAME]` markers
7
+ * extracted into a parallel `stickers[]` array)
7
8
  *
8
9
  * Why this lives in lib/: polygram.js is a top-level script (calls main()
9
10
  * at bottom) and can't be require()'d from a test without starting a bot.
@@ -19,38 +20,102 @@
19
20
  * the placeholder ended up rendered in the user's chat instead of an
20
21
  * actual sticker.
21
22
  *
22
- * Match shape: optional whitespace, `[sticker:`, NAME (alnum/_/-), `]`,
23
- * optional whitespace. NAME must resolve in the supplied stickerMap;
24
- * unknown NAMEs fall through to the text path so a genuine
25
- * "[sticker:foo]" message (e.g. someone joking, or a stale name from an
26
- * older deploy) still reaches the user verbatim.
23
+ * 0.8.0-rc.39 (item: inline sticker regression):
24
+ * Claude evolved to use `[sticker:NAME]` INLINE within longer replies
25
+ * (e.g. "Done! [sticker:pumped]\n\nStripe Mar 2026 created ✅\n…")
26
+ * not as a solo response. The 0.7.5 fix only handled the solo case
27
+ * (full text = tag), so inline tags leaked through the text path
28
+ * verbatim. Now we extract every recognised inline tag, strip it
29
+ * from the text, and surface them in a `stickers[]` array on the
30
+ * result. polygram.js sends the cleaned text first, then each
31
+ * sticker in order. Unknown sticker names still pass through as
32
+ * literal text (someone may genuinely write that string).
33
+ *
34
+ * Match shape: `[sticker:` NAME `]` where NAME is `[A-Za-z0-9_-]+`.
35
+ * The solo-form regex anchors with `^\s*…\s*$`; the inline-form
36
+ * regex is unanchored and global. Both share the same NAME charset.
27
37
  */
28
38
 
39
+ 'use strict';
40
+
29
41
  const STICKER_TAG_RE = /^\s*\[sticker:([A-Za-z0-9_-]+)\]\s*$/;
42
+ const STICKER_TAG_INLINE_RE = /\[sticker:([A-Za-z0-9_-]+)\]/g;
30
43
 
31
44
  function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
32
45
  const trimmed = (text || '').trim();
33
46
 
47
+ // Solo-sticker path: entire response is just the tag.
34
48
  const tagMatch = trimmed.match(STICKER_TAG_RE);
35
49
  if (tagMatch) {
36
50
  const name = tagMatch[1];
37
51
  const fileId = stickerMap[name];
38
52
  if (fileId) {
39
- return { text: '', sticker: fileId, stickerLabel: name, reaction: null };
53
+ return {
54
+ text: '',
55
+ sticker: fileId,
56
+ stickerLabel: name,
57
+ reaction: null,
58
+ stickers: [],
59
+ };
40
60
  }
41
61
  }
42
62
 
63
+ // Solo-emoji shortcuts (single emoji → sticker if mapped, else reaction).
43
64
  const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed)
44
65
  || /^\p{Emoji}️?$/u.test(trimmed);
45
66
 
46
67
  if (emojiOnly && trimmed) {
47
68
  if (emojiToSticker[trimmed]) {
48
- return { text: '', sticker: emojiToSticker[trimmed], stickerLabel: trimmed, reaction: null };
69
+ return {
70
+ text: '',
71
+ sticker: emojiToSticker[trimmed],
72
+ stickerLabel: trimmed,
73
+ reaction: null,
74
+ stickers: [],
75
+ };
49
76
  }
50
- return { text: '', sticker: null, stickerLabel: null, reaction: trimmed };
77
+ return {
78
+ text: '',
79
+ sticker: null,
80
+ stickerLabel: null,
81
+ reaction: trimmed,
82
+ stickers: [],
83
+ };
51
84
  }
52
85
 
53
- return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
86
+ // Inline sticker extraction. Walk every `[sticker:NAME]` in the text;
87
+ // for each NAME present in stickerMap, push to `stickers[]` and remove
88
+ // it from the cleaned text. Unknown NAMEs stay verbatim (someone may
89
+ // genuinely write that string in a message).
90
+ //
91
+ // Whitespace handling: replacing a tag with the empty string can leave
92
+ // a trailing space on its line ("Done! [sticker:x]" → "Done! ") or
93
+ // stack newlines if the tag stood alone on a line. We strip trailing
94
+ // whitespace per-line and collapse runs of 3+ blank lines to 2. We do
95
+ // NOT touch intra-line spacing or code-block indentation.
96
+ const stickers = [];
97
+ const cleaned = trimmed.replace(STICKER_TAG_INLINE_RE, (match, name) => {
98
+ const fileId = stickerMap[name];
99
+ if (fileId) {
100
+ stickers.push({ fileId, name });
101
+ return '';
102
+ }
103
+ return match;
104
+ });
105
+ const tidied = cleaned
106
+ .split('\n')
107
+ .map((line) => line.replace(/[ \t]+$/g, ''))
108
+ .join('\n')
109
+ .replace(/\n{3,}/g, '\n\n')
110
+ .trim();
111
+
112
+ return {
113
+ text: tidied,
114
+ sticker: null,
115
+ stickerLabel: null,
116
+ reaction: null,
117
+ stickers,
118
+ };
54
119
  }
55
120
 
56
- module.exports = { parseResponse, STICKER_TAG_RE };
121
+ module.exports = { parseResponse, STICKER_TAG_RE, STICKER_TAG_INLINE_RE };
@@ -298,6 +298,14 @@ class ProcessManager {
298
298
  }
299
299
 
300
300
  async shutdown() {
301
+ // rc.38: mark "we're shutting down" so the proc.on('close') handler
302
+ // suppresses the misleading `resume-fail` event for signal-driven
303
+ // exits (SIGHUP from tmux pty close, SIGTERM from our own kill,
304
+ // SIGKILL from the kill-timeout escalator). Pre-rc.38 every deploy
305
+ // logged a `resume-fail` for every CLI-pm chat AND cleared the
306
+ // saved session_id, forcing a fresh resume on the next user turn
307
+ // — slower first turn, fresh context — for no real reason.
308
+ this._shuttingDown = true;
301
309
  const keys = Array.from(this.procs.keys());
302
310
  for (const key of keys) await this.kill(key);
303
311
  }
@@ -542,7 +550,22 @@ class ProcessManager {
542
550
  this.procs.delete(sessionKey);
543
551
  // A slot freed up → maybe an LRU waiter can run now.
544
552
  this._maybeSignalLruWaiter();
545
- if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId) {
553
+ // rc.38: only fire `resume-fail` for UNEXPECTED non-zero exits.
554
+ // Signal-driven exits during planned shutdown (SIGHUP from tmux
555
+ // pty close on `tmux kill-session`, SIGTERM from our own kill(),
556
+ // SIGKILL from the kill-timeout escalator) are NOT resume
557
+ // failures — the saved session_id is still valid, we'd just be
558
+ // clearing it for nothing and logging misleading noise on every
559
+ // deploy. The real signal we care about is "the CLI rejected a
560
+ // stale or corrupt resume id at startup with a non-zero exit
561
+ // while polygram is healthy."
562
+ const isPlannedShutdown = this._shuttingDown
563
+ || code === null // killed without an exit code
564
+ || code === 129 // SIGHUP (tmux pty close on deploy kickstart)
565
+ || code === 143 // SIGTERM (our own kill())
566
+ || code === 137; // SIGKILL (kill-timeout escalation)
567
+ if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId
568
+ && !isPlannedShutdown) {
546
569
  this._logEvent('resume-fail', { session_key: sessionKey, session_id: ctx.existingSessionId, code });
547
570
  try { this.db.clearSessionId(sessionKey); } catch (err) {
548
571
  this.logger.error(`[${entry.label}] clearSessionId failed: ${err.message}`);
@@ -551,6 +574,16 @@ class ProcessManager {
551
574
  if (this.onClose) this.onClose(sessionKey, code, entry);
552
575
  });
553
576
 
577
+ // rc.38: stdin error listener. Async EIO writes (the kernel reports
578
+ // them after the subprocess pipe closed during shutdown) had no
579
+ // listener pre-rc.38 → bubbled to the global uncaughtException
580
+ // handler → emitted misleading `uncaught-exception: write EIO`
581
+ // events on every deploy. Listening swallows that path; runtime
582
+ // stdin errors (rare; usually a real problem) still log here.
583
+ proc.stdin?.on?.('error', (err) => {
584
+ this.logger.error(`[${entry.label}] stdin error: ${err.message}`);
585
+ });
586
+
554
587
  proc.on('error', (err) => {
555
588
  this.logger.error(`[${entry.label}] proc error: ${err.message}`);
556
589
  entry.closed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.37",
3
+ "version": "0.8.0-rc.39",
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
@@ -2532,6 +2532,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2532
2532
  // applyChain — so it serializes after any in-flight
2533
2533
  // QUEUED apply and lands as the final visible reaction.
2534
2534
  await reactor.setState('AUTOSTEERED');
2535
+ // rc.38: stop the reactor's STALL/TIMEOUT timers. Pre-rc.38
2536
+ // the timers stayed armed, holding setTimeout handles for
2537
+ // up to 30s and pinning the closure (and the bot/chatId
2538
+ // captures) until they fired. AUTOSTEERED is terminal — no
2539
+ // further state changes — so the timers serve no purpose
2540
+ // and just delay GC. One-line patch; small steady-state
2541
+ // heap relief in busy chats.
2542
+ reactor.stop();
2535
2543
  markReplied();
2536
2544
  return;
2537
2545
  }
@@ -2729,6 +2737,28 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2729
2737
  const parsed = parseResponse(result.text);
2730
2738
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
2731
2739
 
2740
+ // 0.8.0-rc.39: send any inline stickers Claude embedded with
2741
+ // `[sticker:NAME]` markers (parseResponse stripped them from
2742
+ // parsed.text and surfaced them in parsed.stickers[]). Send AFTER
2743
+ // the text reply lands so the sticker reads as punctuation on the
2744
+ // message, not as a leading icon. Failures are logged but never
2745
+ // block the rest of the reply — a missing sticker is a soft UX
2746
+ // miss, not a turn failure.
2747
+ const sendInlineStickers = async () => {
2748
+ if (!parsed.stickers || parsed.stickers.length === 0) return;
2749
+ for (const s of parsed.stickers) {
2750
+ try {
2751
+ await tg(bot, 'sendSticker', {
2752
+ chat_id: chatId,
2753
+ sticker: s.fileId,
2754
+ ...(threadId && { message_thread_id: threadId }),
2755
+ }, { ...outMeta, stickerName: s.name, source: 'inline-sticker' });
2756
+ } catch (err) {
2757
+ console.error(`[${label}] inline sendSticker(${s.name}) failed: ${err.message}`);
2758
+ }
2759
+ }
2760
+ };
2761
+
2732
2762
  // OpenClaw's preview-becomes-final flow:
2733
2763
  //
2734
2764
  // 1. flushDraft() — drain any pending throttled edit so the
@@ -2749,6 +2779,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2749
2779
  if (fin.finalEditOk) {
2750
2780
  // Preview was successfully edited to the final text.
2751
2781
  // No follow-up messages needed.
2782
+ await sendInlineStickers();
2752
2783
  await cleanupArchivedBubbles();
2753
2784
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
2754
2785
  markReplied();
@@ -2794,6 +2825,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2794
2825
  console.error(`[${label}] partial-delivery warning failed: ${warnErr.message}`);
2795
2826
  }
2796
2827
  }
2828
+ await sendInlineStickers();
2797
2829
  await cleanupArchivedBubbles();
2798
2830
  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) || '?'}`);
2799
2831
  markReplied();
@@ -2836,6 +2868,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2836
2868
  });
2837
2869
  }
2838
2870
 
2871
+ await sendInlineStickers();
2839
2872
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
2840
2873
  markReplied();
2841
2874
  } catch (err) {
@@ -2868,6 +2901,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2868
2901
  } finally {
2869
2902
  stopTyping();
2870
2903
  reactor.stop();
2904
+ // rc.38: defensive clear-on-exit for ✍ reactions. Pre-rc.38 only
2905
+ // the success path (line ~2622), the abort path (line ~2858), and
2906
+ // the tool-only-completion path (line ~2681) cleared
2907
+ // autosteeredRefs. The plain error path (`if (result.error)` →
2908
+ // throw at ~2612), the empty-response fallback failure (~2714),
2909
+ // and the streamer-overflow path could all leave ✍ reactions
2910
+ // stuck on follow-ups whose buffer entries had never been
2911
+ // drained by PostToolBatch. The clear is idempotent (the second
2912
+ // call returns 0 against an already-emptied map) so adding it
2913
+ // here covers ALL exit paths without double-clearing harm.
2914
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2871
2915
  }
2872
2916
  }
2873
2917