polygram 0.10.0-rc.47 → 0.10.0-rc.50

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.10.0-rc.47",
4
+ "version": "0.10.0-rc.50",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -74,6 +74,19 @@ const PATTERNS = {
74
74
  // image-failure verb co-located with "image" or "photo".
75
75
  imageProcess: /(could not process|cannot process|failed to (process|load|decode)|unsupported|invalid|corrupt(?:ed)?)[^\n]{0,80}\b(image|photo)\b|\b(image|photo)\b[^\n]{0,80}(could not process|failed to (process|load|decode)|is (invalid|corrupted|unsupported))/i,
76
76
 
77
+ // rc.50: lib/process-manager.js `_awaitLruSlot` rejects when no
78
+ // backend slot is available within `lruWaitMs` (default 5 min).
79
+ // Symptom of an upstream wedge (an inFlight tmux process can't be
80
+ // LRU-evicted; if its turn is wedged the slot stays held for the
81
+ // whole idle-ceiling). Production 2026-05-24 (shumorobot Music):
82
+ // hit twice during the wedged-"yes" turn that took 30 min to
83
+ // surface. Pre-rc.50 the user got `Hit a snag: lru wait timed
84
+ // out after 300000ms` which is opaque; now they get a hint that
85
+ // it's a busy/queued condition and a retry will probably work.
86
+ // Placed BEFORE the generic `timeout` pattern so the LRU phrasing
87
+ // wins over the broader timeout match.
88
+ lruWaitTimeout: /lru wait timed out/i,
89
+
77
90
  // rc.47: tmux backend's TMUX_TURN_TIMEOUT after H3 idle-ceiling fired.
78
91
  // Production wedge 2026-05-24 msg 1020: a Bash tool's `PreToolUse`
79
92
  // fired but `PostToolUse` never came — claude waited forever for the
@@ -115,6 +128,7 @@ const USER_MESSAGES = {
115
128
  missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
116
129
  imageProcess: '🖼 One of the images in this conversation can\'t be re-processed by Claude — likely an older one in the history. Starting a fresh session for this chat.',
117
130
  tmuxToolWedge: '🔧 A tool didn\'t return in time — I cut it off. Most often this is a Bash command waiting on something external (a server, a file lock, an interactive prompt). Try resending; if it happens again, break the task into smaller steps.',
131
+ lruWaitTimeout: '⏳ Other chats are busy — couldn\'t free up a backend slot in time. Try resending in a moment; if it keeps happening, an operator may need to raise the warm-process cap.',
118
132
  timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
119
133
  format: '⚠️ Invalid request format. Try rephrasing or /new.',
120
134
  // Used both for in-flight retry attempts AND for the post-retry-failed
@@ -251,6 +251,26 @@ const DEFAULT_READY_DEBUG_QUIET_MS = 1000;
251
251
  // combined with the "Resuming the full session" rationale.
252
252
  const SESSION_AGE_PROMPT_RE = /Resuming the full session.*Resume from summary/s;
253
253
 
254
+ // rc.48 (production 2026-05-24 post-rc.47 deploy, shumorobot HOME):
255
+ // rc.43's session-age menu auto-dismiss + rc.44's deadline reset
256
+ // gave `/compact` a fresh `readyTimeoutMs` (120 s) budget — but on
257
+ // an 8h+ aged session compact STILL took >120 s (observed pane:
258
+ // `✶ Compacting conversation… (1m 58s) ▰▰…▱ 73%` at the timeout
259
+ // moment). The single one-shot reset is too coarse.
260
+ //
261
+ // Fix: while `/compact` is in progress AND making observable progress
262
+ // (the captured signature changes between polls — elapsed time,
263
+ // progress bar, percentage), keep extending the deadline. Compact
264
+ // that genuinely stalls (same signature across multiple polls) is
265
+ // still allowed to time out so a real wedge isn't masked.
266
+ //
267
+ // Match captures the distinctive header `Compacting conversation…`
268
+ // (with ellipsis or three dots) plus everything up to the
269
+ // percentage, giving a signature that changes as compact advances:
270
+ // elapsed wall-time (1m 58s → 1m 59s), progress bar density
271
+ // (▰…▱ → ▰▰…▱), AND percentage (73% → 74%).
272
+ const COMPACT_PROGRESS_RE = /Compacting (?:conversation|context)(?:…|\.\.\.)[\s\S]{0,300}?\d+%/i;
273
+
254
274
  // R7: sentinel returned by _awaitTurnComplete when its poll loop is
255
275
  // stopped by the caller's absolute-deadline abort (rather than by a
256
276
  // real READY quiescence or its own internal timeout). _runTurn maps
@@ -2674,6 +2694,12 @@ class TmuxProcess extends Process {
2674
2694
  // prompt this wait, so we don't fire Enter every poll if claude
2675
2695
  // is slow to re-render after dismissing it.
2676
2696
  let sessionAgePromptDismissed = false;
2697
+ // rc.48: track the most recently observed `/compact` progress
2698
+ // signature so we can extend the deadline ONLY when compact is
2699
+ // making real progress. A genuinely-stalled compact (same
2700
+ // signature N polls in a row) will not extend, so the wedge
2701
+ // safety net is preserved.
2702
+ let lastCompactSignature = null;
2677
2703
  if (this.pollScheduler) this.pollScheduler.acquire();
2678
2704
  try {
2679
2705
  while (this._now() < deadline) {
@@ -2716,6 +2742,13 @@ class TmuxProcess extends Process {
2716
2742
  // the menu but the post-dismiss wait still timed out.
2717
2743
  // Reset the deadline so compact has a full
2718
2744
  // `readyTimeoutMs` budget from dismissal time.
2745
+ //
2746
+ // rc.48: the rc.44 one-shot reset isn't enough either —
2747
+ // observed on shumorobot post-rc.47 deploy with an even
2748
+ // older session, compact ran >120 s and still timed out.
2749
+ // The new compact-progress block below extends the deadline
2750
+ // each poll that observes progress, with the rc.44 reset
2751
+ // here as the initial budget for the FIRST compact tick.
2719
2752
  deadline = this._now() + this.readyTimeoutMs;
2720
2753
  // Reset readiness clock + prev-pane so the menu's content
2721
2754
  // doesn't satisfy the byte-stability check while claude is
@@ -2725,6 +2758,45 @@ class TmuxProcess extends Process {
2725
2758
  await this._waitForNextTick();
2726
2759
  continue;
2727
2760
  }
2761
+ // rc.48: `/compact` (triggered by the session-age dismissal,
2762
+ // OR by any future code path that runs compact during startup)
2763
+ // shows a progress UI in the pane. If we can SEE compact making
2764
+ // progress (signature changes between polls), extend the
2765
+ // deadline so compact has unbounded time to finish AS LONG AS
2766
+ // it's progressing. A stalled compact (same signature across
2767
+ // multiple polls) is still bounded by the existing deadline —
2768
+ // the wedge safety net is preserved.
2769
+ const compactMatch = lastBuf.match(COMPACT_PROGRESS_RE);
2770
+ if (compactMatch) {
2771
+ const currentSignature = compactMatch[0];
2772
+ if (currentSignature !== lastCompactSignature) {
2773
+ // Compact made observable progress (elapsed time advanced,
2774
+ // progress bar grew, or percentage incremented). Extend
2775
+ // the deadline and emit a forensics event so a soak can
2776
+ // count how often this kicks in.
2777
+ lastCompactSignature = currentSignature;
2778
+ deadline = this._now() + this.readyTimeoutMs;
2779
+ readySinceAt = null;
2780
+ prevBuf = null;
2781
+ this.emit('compact-progress', {
2782
+ sessionId: this.claudeSessionId,
2783
+ backend: 'tmux',
2784
+ });
2785
+ }
2786
+ // Whether progressing or stalled, skip the ready-hint check
2787
+ // this poll — compact's progress UI is on the pane, not the
2788
+ // ready hint. The next poll re-evaluates.
2789
+ await this._waitForNextTick();
2790
+ continue;
2791
+ } else if (lastCompactSignature != null) {
2792
+ // Compact finished (no longer visible in pane). Clear the
2793
+ // tracker; the normal ready-check below resumes. Do NOT
2794
+ // reset the deadline here — the existing deadline still has
2795
+ // its last-extended budget, which is the right window for
2796
+ // claude to repaint the ready hint after compact returns
2797
+ // control.
2798
+ lastCompactSignature = null;
2799
+ }
2728
2800
  // Ready ⇔ the hint is on the pane AND the pane is identical to
2729
2801
  // the previous poll (the MCP-loading repaint storm has
2730
2802
  // stopped). The first poll has no previous buffer to compare,
@@ -3354,4 +3426,6 @@ module.exports = {
3354
3426
  CLAUDE_CLI_PINNED_VERSION,
3355
3427
  // rc.43 — exported for unit-test coverage of the menu pattern.
3356
3428
  SESSION_AGE_PROMPT_RE,
3429
+ // rc.48 — exported for unit-test coverage of the /compact progress pattern.
3430
+ COMPACT_PROGRESS_RE,
3357
3431
  };
@@ -117,7 +117,12 @@ const ALLOWED_TRANSITIONS = Object.freeze({
117
117
  // PASTED_UNCONFIRMED intermediate. rc.35 production caught this as
118
118
  // log noise once Commit 3 (`_awaitSettle`) started consuming
119
119
  // predicate fields more strictly.
120
- [TurnPhase.QUEUED]: new Set([TurnPhase.PASTED_UNCONFIRMED, TurnPhase.PASTE_PARKED, TurnPhase.FAILED]),
120
+ // rc.49 (shumorobot HOME 2026-05-24): SUBMITTED is reachable
121
+ // directly from QUEUED when the TUI's `jsonl:user-message` event
122
+ // races ahead of the `paste:returned` event-loop callback that
123
+ // normally lands first and advances QUEUED → PASTED_UNCONFIRMED.
124
+ // Symmetric to the rc.35→rc.36 PASTE_PARKED edge; same fix shape.
125
+ [TurnPhase.QUEUED]: new Set([TurnPhase.PASTED_UNCONFIRMED, TurnPhase.PASTE_PARKED, TurnPhase.SUBMITTED, TurnPhase.FAILED]),
121
126
  [TurnPhase.PASTED_UNCONFIRMED]: new Set([TurnPhase.PASTE_PARKED, TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
122
127
  [TurnPhase.PASTE_PARKED]: new Set([TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
123
128
  [TurnPhase.SUBMITTED]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
@@ -39,6 +39,21 @@ function createSdkCallbacks({
39
39
  getChatIdFromKey,
40
40
  getThreadIdFromKey,
41
41
  logger = console,
42
+ // rc.50 (production 2026-05-24, shumorobot HOME): the
43
+ // autonomous-wakeup path (`onAutonomousAssistantMessage` below)
44
+ // was bypassing parseResponse + sanitizeReply + deliverReplies
45
+ // entirely, so `[sticker:pumped]` showed up as literal text and
46
+ // `No response requested.` leaked through (the rc.45 sanitizer
47
+ // only protected the regular reply path). Inject the full
48
+ // pipeline so autonomous messages get the same treatment as
49
+ // streamed bot replies. All optional — fallback below preserves
50
+ // the pre-rc.50 raw-sendMessage behavior when any dep is unwired
51
+ // (e.g. an old caller of createSdkCallbacks).
52
+ parseResponse = null,
53
+ sanitizeAssistantReply = null,
54
+ chunkMarkdownText = null,
55
+ deliverReplies = null,
56
+ chunkBudget = 3500,
42
57
  } = {}) {
43
58
  // rc.9: typing-indicator state for autosteer NEW-TURN extraction.
44
59
  // Keyed by sessionKey. extra-turn-started installs a 4-second
@@ -202,6 +217,25 @@ function createSdkCallbacks({
202
217
  // ScheduleWakeup case where the agent self-fires without an
203
218
  // inbound user message. Best-effort send: failures are logged
204
219
  // but don't propagate.
220
+ //
221
+ // rc.50 (production 2026-05-24): pre-fix this called
222
+ // `tg(bot, 'sendMessage', { text: <raw> })` directly, bypassing
223
+ // parseResponse + sanitizeReply + deliverReplies. Two
224
+ // user-visible bugs as a result:
225
+ // - `[sticker:NAME]` and `[react:EMOJI]` tags appeared as
226
+ // literal text in the chat (no sticker bubble fired, no
227
+ // reaction set).
228
+ // - The CLI canned-string leak `No response requested.`
229
+ // reached Telegram (the rc.45 sanitizer was wired into the
230
+ // regular reply path only — this path was unprotected).
231
+ // Fix: route the autonomous text through the same pipeline as
232
+ // bot-reply-stream — parseResponse → sanitize → chunk →
233
+ // deliverReplies → send inline stickers/sticker. No user-msg to
234
+ // reply to or react against; inline reactions are logged-and-
235
+ // dropped instead of applied. Optional deps: if the caller of
236
+ // createSdkCallbacks didn't inject the pipeline pieces, fall
237
+ // back to the pre-rc.50 raw-sendMessage path (preserves
238
+ // back-compat for older test harnesses).
205
239
  onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
206
240
  try {
207
241
  // Backend-shape normalization: SDK emits the raw SDKMessage
@@ -218,21 +252,129 @@ function createSdkCallbacks({
218
252
  logger.error?.(`[${botName}] autonomous wakeup: bot not ready, dropping ${text.length} chars`);
219
253
  return;
220
254
  }
221
- const params = {
222
- chat_id: chatId,
223
- text,
224
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
225
- };
226
- // Don't await keep the pm-sdk event loop unblocked.
227
- tg(bot, 'sendMessage', params,
228
- { source: 'autonomous-wakeup', botName }).catch((err) => {
229
- logger.error?.(`[${botName}] autonomous wakeup send failed: ${err.message}`);
255
+
256
+ // Pre-rc.50 fallback: any pipeline dep missing → raw send.
257
+ // Used in tests or partial-wire scenarios. Production wires
258
+ // all five (parseResponse, sanitizeAssistantReply,
259
+ // chunkMarkdownText, deliverReplies, chunkBudget).
260
+ const haveFullPipeline = (typeof parseResponse === 'function')
261
+ && (typeof sanitizeAssistantReply === 'function')
262
+ && (typeof chunkMarkdownText === 'function')
263
+ && (typeof deliverReplies === 'function');
264
+
265
+ if (!haveFullPipeline) {
266
+ const params = {
267
+ chat_id: chatId,
268
+ text,
269
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
270
+ };
271
+ tg(bot, 'sendMessage', params,
272
+ { source: 'autonomous-wakeup', botName }).catch((err) => {
273
+ logger.error?.(`[${botName}] autonomous wakeup send failed: ${err.message}`);
274
+ });
275
+ logEvent('autonomous-wakeup-message', {
276
+ chat_id: chatId,
277
+ session_key: sessionKey,
278
+ thread_id: threadIdRaw,
279
+ text_len: text.length,
280
+ pipeline: 'raw-fallback',
230
281
  });
282
+ return;
283
+ }
284
+
285
+ // Full pipeline (mirrors polygram.js handleMessage's
286
+ // bot-reply-stream branch). Don't await — keep the pm-sdk
287
+ // event loop unblocked; surface failures via .catch.
288
+ (async () => {
289
+ const parsed = parseResponse(text);
290
+ if (parsed.text) {
291
+ const sanitized = sanitizeAssistantReply(parsed.text);
292
+ if (sanitized.replaced) {
293
+ logEvent('canned-reply-suppressed', {
294
+ chat_id: chatId,
295
+ msg_id: null, // no inbound msg
296
+ original: sanitized.original,
297
+ source: 'autonomous-wakeup',
298
+ });
299
+ parsed.text = sanitized.text;
300
+ }
301
+ }
302
+
303
+ // Text first (so any follow-up stickers read as
304
+ // punctuation, matching the bot-reply-stream order).
305
+ if (parsed.text) {
306
+ const chunks = chunkMarkdownText(parsed.text, chunkBudget);
307
+ await deliverReplies({
308
+ bot,
309
+ send: (b, method, params, m) => tg(b, method, params, m),
310
+ chatId,
311
+ threadId,
312
+ chunks,
313
+ replyToMessageId: null, // no inbound msg
314
+ meta: { source: 'autonomous-wakeup', botName },
315
+ logger,
316
+ });
317
+ }
318
+
319
+ // Solo-sticker path (parseResponse returns a single
320
+ // sticker when the WHOLE text was just `[sticker:NAME]`).
321
+ if (parsed.sticker) {
322
+ await tg(bot, 'sendSticker', {
323
+ chat_id: chatId,
324
+ sticker: parsed.sticker,
325
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
326
+ }, {
327
+ source: 'autonomous-wakeup-sticker', botName,
328
+ stickerName: parsed.stickerLabel || null,
329
+ }).catch((err) => {
330
+ logger.error?.(`[${botName}] autonomous-wakeup sendSticker failed: ${err.message}`);
331
+ });
332
+ }
333
+
334
+ // Inline stickers (parsed.stickers[] when the text
335
+ // embedded one or more `[sticker:NAME]` tags). Send
336
+ // sequentially so order matches the order in the text.
337
+ for (const s of (parsed.stickers || [])) {
338
+ try {
339
+ await tg(bot, 'sendSticker', {
340
+ chat_id: chatId,
341
+ sticker: s.fileId,
342
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
343
+ }, {
344
+ source: 'autonomous-wakeup-inline-sticker', botName,
345
+ stickerName: s.name,
346
+ });
347
+ } catch (err) {
348
+ logger.error?.(`[${botName}] autonomous-wakeup inline sendSticker(${s.name}) failed: ${err.message}`);
349
+ }
350
+ }
351
+
352
+ // Reactions: parsed.reaction (solo) and parsed.reactions[]
353
+ // (inline). Autonomous-wakeup has no target msg to react
354
+ // against (the message isn't replying to anything). Log
355
+ // and drop so this surfaces in forensics if a future agent
356
+ // starts using react-tags in autonomous output.
357
+ const allReactions = [
358
+ ...(parsed.reaction ? [parsed.reaction] : []),
359
+ ...(parsed.reactions || []),
360
+ ];
361
+ if (allReactions.length > 0) {
362
+ logEvent('autonomous-wakeup-reactions-dropped', {
363
+ chat_id: chatId,
364
+ session_key: sessionKey,
365
+ dropped: allReactions,
366
+ });
367
+ }
368
+ })().catch((err) => {
369
+ logger.error?.(`[${botName}] autonomous wakeup pipeline failed: ${err.message}`);
370
+ });
371
+
231
372
  logEvent('autonomous-wakeup-message', {
232
373
  chat_id: chatId,
233
374
  session_key: sessionKey,
234
375
  thread_id: threadIdRaw,
235
376
  text_len: text.length,
377
+ pipeline: 'full',
236
378
  });
237
379
  } catch (err) {
238
380
  logger.error?.(`[${botName}] autonomous wakeup handler: ${err.message}`);
@@ -93,6 +93,17 @@ const METHODS_WITHOUT_MSG = new Set([
93
93
  'deleteMessage',
94
94
  'editMessageReplyMarkup',
95
95
  'editMessageText',
96
+ // rc.49 (production wedge 2026-05-24): sendChatAction is the
97
+ // typing indicator. lib/sdk/callbacks.js fires it every 4s during
98
+ // a turn. Telegram returns `true` (boolean, not a message object)
99
+ // — `res?.message_id ?? 0` evaluates to 0, so markOutboundSent
100
+ // would UPDATE the row to (chat_id, msg_id=0). The 2nd+ tick in
101
+ // the same chat then UNIQUE-collides on (chat_id, 0), leaving
102
+ // pending rows stranded. shumorobot post-rc.48 deploy: shutdown
103
+ // drain reported "marked 236 stale pending rows as failed" — all
104
+ // 236 had source='extra-turn-typing' and text=''. Excluding
105
+ // sendChatAction from the row-tracking set is the surgical fix.
106
+ 'sendChatAction',
96
107
  ]);
97
108
 
98
109
  // Derive the row's `text` column. sendSticker has no text/caption, so we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.47",
3
+ "version": "0.10.0-rc.50",
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
@@ -2130,6 +2130,14 @@ async function main() {
2130
2130
  classifyToolName, announce, shouldAnnounce, contextHintShown,
2131
2131
  extractAssistantText, getChatIdFromKey, getThreadIdFromKey,
2132
2132
  logger: console,
2133
+ // rc.50: full reply-pipeline deps for the autonomous-wakeup path
2134
+ // (see `onAutonomousAssistantMessage` in lib/sdk/callbacks.js).
2135
+ // Mirrors what bot-reply-stream uses in handleMessage so
2136
+ // [sticker:NAME] / [react:EMOJI] tags get processed and the
2137
+ // rc.45 canned-string sanitizer fires.
2138
+ parseResponse, sanitizeAssistantReply,
2139
+ chunkMarkdownText, deliverReplies,
2140
+ chunkBudget: TG_CHUNK_BUDGET,
2133
2141
  });
2134
2142
  // 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
2135
2143
  // reactor, stream chunk → bubble edit, etc.) move from the underlying