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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/autosteer-buffer.js +13 -1
- package/lib/parse-response.js +76 -11
- package/lib/process-manager.js +34 -1
- package/package.json +1 -1
- package/polygram.js +44 -0
|
@@ -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.
|
|
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",
|
package/lib/autosteer-buffer.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/lib/parse-response.js
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 {
|
|
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 {
|
|
69
|
+
return {
|
|
70
|
+
text: '',
|
|
71
|
+
sticker: emojiToSticker[trimmed],
|
|
72
|
+
stickerLabel: trimmed,
|
|
73
|
+
reaction: null,
|
|
74
|
+
stickers: [],
|
|
75
|
+
};
|
|
49
76
|
}
|
|
50
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
text: '',
|
|
79
|
+
sticker: null,
|
|
80
|
+
stickerLabel: null,
|
|
81
|
+
reaction: trimmed,
|
|
82
|
+
stickers: [],
|
|
83
|
+
};
|
|
51
84
|
}
|
|
52
85
|
|
|
53
|
-
|
|
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 };
|
package/lib/process-manager.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|