polygram 0.12.0-rc.15 → 0.12.0-rc.16

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.
@@ -1249,6 +1249,7 @@ class CliProcess extends Process {
1249
1249
  if (pending.hardTimer) clearTimeout(pending.hardTimer);
1250
1250
  if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
1251
1251
  if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
1252
+ const hadReplyToolCalls = pending.replies.length > 0;
1252
1253
  let text = pending.replies.join('\n\n');
1253
1254
  // 0.12 Phase 1.7 fallback: if no reply tool calls landed (claude ended
1254
1255
  // the turn without calling mcp__polygram-bridge__reply), use the Stop
@@ -1266,12 +1267,14 @@ class CliProcess extends Process {
1266
1267
  // to appear free in dashboards.
1267
1268
  const result = {
1268
1269
  text,
1269
- // Review F#2: dispatcher has ALREADY delivered text to Telegram on each
1270
- // reply tool call (incremental real-time UX is the channels delivery
1271
- // model). polygram.js's post-pm.send pipeline must short-circuit its
1272
- // streamer.finalize / deliverReplies branch otherwise every turn
1273
- // delivers twice. Logging + DB transcript still use result.text.
1274
- alreadyDelivered: true,
1270
+ // Review F#2: when claude used reply tool calls, the dispatcher ALREADY
1271
+ // delivered that text to Telegram incrementally polygram.js must
1272
+ // short-circuit its deliverReplies branch or every turn delivers twice.
1273
+ // BUT a turn finalized via the Stop fallback (no reply tool calls — the
1274
+ // stuck-turn case) has delivered NOTHING; marking it alreadyDelivered
1275
+ // would resolve the turn silently and the user still sees nothing. So
1276
+ // only claim already-delivered when reply tool calls actually fired.
1277
+ alreadyDelivered: hadReplyToolCalls,
1275
1278
  sessionId: this.claudeSessionId,
1276
1279
  cost: null, // Channels protocol doesn't expose per-turn cost
1277
1280
  duration,
@@ -1632,6 +1635,22 @@ class CliProcess extends Process {
1632
1635
  _handleHookEvent(ev) {
1633
1636
  if (!ev || typeof ev !== 'object') return;
1634
1637
 
1638
+ // rc.16 observability: emit once when the FIRST hook event arrives for
1639
+ // this session, confirming the claude→ndjson→tail pipeline is actually
1640
+ // flowing. The 2026-06-02 stuck turn had a session whose hook ndjson was
1641
+ // 0 bytes — claude emitted no hooks polygram could see, so no Stop ever
1642
+ // arrived to finalize the turn. Without this signal that's invisible: a
1643
+ // turn that hangs with NO `cli-hook-stream-live` for its session means the
1644
+ // hook pipeline is dead for it (distinct from "Stop fired but wasn't
1645
+ // acted on", which `cli-turn-resolved-by-stop` now covers).
1646
+ if (!this._sawHookStream) {
1647
+ this._sawHookStream = true;
1648
+ this._logEvent('cli-hook-stream-live', {
1649
+ session_id: this.claudeSessionId,
1650
+ first_event: ev.type,
1651
+ });
1652
+ }
1653
+
1635
1654
  // 0.12 Phase 1.8 (Finding 0.4.A): per-event lag measurement.
1636
1655
  // polygram_received_at_ms is stamped by the helper subprocess at write
1637
1656
  // time; subtracting from Date.now() gives the helper-write → tail-emit
@@ -1740,15 +1759,47 @@ class CliProcess extends Process {
1740
1759
  return;
1741
1760
  }
1742
1761
 
1743
- case 'Stop':
1744
- // Phase 1.7 (TODO) will use this as the authoritative turn-end
1745
- // signal with stopGraceMs. For now: pass through as 'stop-hook'
1746
- // event so the resolver in Phase 1.7 can subscribe.
1747
- this.emit('stop-hook', {
1762
+ case 'Stop': {
1763
+ // 0.12.0 Phase 1.7 (rc.16): Stop is the AUTHORITATIVE turn-end signal.
1764
+ const info = {
1748
1765
  stopHookActive: ev.stopHookActive,
1749
1766
  lastAssistantMessage: ev.lastAssistantMessage,
1750
1767
  backend: this.backend,
1751
- });
1768
+ };
1769
+ // Turns already resolving via a reply quiet-window consume this via
1770
+ // their per-turn onStop listener (the text-fallback rescue inside
1771
+ // _resolveTurn). Emit first so that path runs synchronously and any
1772
+ // grace-pending turn is finalized + removed before the check below.
1773
+ this.emit('stop-hook', info);
1774
+
1775
+ // THE FIX (2026-06-02 stuck-turn): a turn that ended WITHOUT a reply
1776
+ // tool call has no quiet-window to fire _resolveTurn — pre-fix it hung
1777
+ // until the 30-min wall-clock backstop while the unknown-prompt
1778
+ // watchdog spun. Stop IS the turn-end; resolve the single in-flight
1779
+ // turn now (reply text if any, else last_assistant_message). After the
1780
+ // emit above, a grace-pending turn is already gone, so this only fires
1781
+ // for the no-reply case. Gated on exactly one in-flight turn — Stop
1782
+ // carries no turn_id, so we cannot attribute it when turns are
1783
+ // concurrent (the M1 cross-attribution hazard).
1784
+ if (this.pendingTurns.size === 1) {
1785
+ const [turnId, p] = [...this.pendingTurns.entries()][0];
1786
+ if (!p._stopGracePending) {
1787
+ p._stopHookData = info;
1788
+ this._logEvent('cli-turn-resolved-by-stop', {
1789
+ turn_id: turnId,
1790
+ reply_count: p.replies?.length || 0,
1791
+ via_text_fallback: (p.replies?.length || 0) === 0,
1792
+ session_id: this.claudeSessionId,
1793
+ });
1794
+ this._finalizeTurn(turnId);
1795
+ }
1796
+ } else if (this.pendingTurns.size > 1) {
1797
+ // Can't attribute Stop to one of several concurrent turns — surface
1798
+ // it so a turn that waited for its grace timer (instead of resolving
1799
+ // on Stop) is explained in the events DB.
1800
+ this._logEvent('cli-stop-unattributed', { pending_count: this.pendingTurns.size });
1801
+ }
1802
+
1752
1803
  // 0.12.0-rc.13 proactive compaction warning: on turn-end, if enabled
1753
1804
  // for this chat and not already warned this climb, sample context
1754
1805
  // occupancy from the transcript and warn (propose /compact) BEFORE
@@ -1758,6 +1809,7 @@ class CliProcess extends Process {
1758
1809
  this._maybeProactiveCompactionWarn(ev.transcriptPath);
1759
1810
  }
1760
1811
  return;
1812
+ }
1761
1813
 
1762
1814
  case 'PreCompact':
1763
1815
  // 0.12.0-rc.13: auto-compaction is the event that detaches the
@@ -0,0 +1,50 @@
1
+ /**
2
+ * album-reactions — apply one status reaction to every message of a Telegram
3
+ * album (the anchor + its siblings), so a multi-file send shows the same emoji
4
+ * on each item instead of only the first.
5
+ *
6
+ * Background: Telegram delivers an album as N separate messages sharing a
7
+ * media_group_id; polygram coalesces them into ONE turn anchored on the first.
8
+ * The status reactor therefore only ever reacted to that anchor, leaving the
9
+ * sibling files with no visible reaction (the rc.16 observation). This mirrors
10
+ * the reactor's emoji onto the siblings.
11
+ *
12
+ * Semantics:
13
+ * - The ANCHOR (first id) is awaited so a failure surfaces to the reactor's
14
+ * own error handling (same as the single-message path).
15
+ * - SIBLINGS are best-effort: a failure on one must not drop the anchor's
16
+ * reaction or the other siblings (and must not throw — reactions are
17
+ * cosmetic). They also can't share the anchor's fate of being retried.
18
+ * - Calls are sequential to respect Telegram's setMessageReaction rate limit
19
+ * (~5/s/chat) — an album is ≤10 items so this stays well within budget.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ /**
25
+ * @param {object} opts
26
+ * @param {Function} opts.tg async (bot, method, params, meta) => any
27
+ * @param {*} opts.bot
28
+ * @param {string} opts.chatId
29
+ * @param {number[]} opts.msgIds [anchor, ...siblings] — anchor first
30
+ * @param {string|null} opts.emoji emoji to set, or null/'' to clear
31
+ * @param {string} [opts.botName]
32
+ */
33
+ async function applyReactionToMessages({ tg, bot, chatId, msgIds, emoji, botName } = {}) {
34
+ const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
35
+ const ids = Array.isArray(msgIds) ? msgIds : [];
36
+ for (let i = 0; i < ids.length; i++) {
37
+ const params = { chat_id: chatId, message_id: ids[i], reaction };
38
+ const meta = {
39
+ source: i === 0 ? 'status-reaction' : 'status-reaction-album-sibling',
40
+ botName,
41
+ };
42
+ if (i === 0) {
43
+ await tg(bot, 'setMessageReaction', params, meta); // anchor: surface failure
44
+ } else {
45
+ await tg(bot, 'setMessageReaction', params, meta).catch(() => {}); // siblings: best-effort
46
+ }
47
+ }
48
+ }
49
+
50
+ module.exports = { applyReactionToMessages };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.15",
3
+ "version": "0.12.0-rc.16",
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
@@ -97,6 +97,7 @@ const { startTyping } = require('./lib/telegram/typing');
97
97
  // consumer is lib/handlers/download.js.
98
98
  const { createReactionManager, classifyToolName } = require('./lib/telegram/reactions');
99
99
  const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
100
+ const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
100
101
  const { classify: classifyError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
101
102
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
102
103
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
@@ -998,13 +999,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
998
999
  const availableEmojis = await getReactionAllowlist(bot, chatId);
999
1000
  const reactor = createReactionManager({
1000
1001
  apply: async (emoji) => {
1001
- const params = {
1002
- chat_id: chatId,
1003
- message_id: msg.message_id,
1004
- reaction: emoji ? [{ type: 'emoji', emoji }] : [],
1005
- };
1006
- await tg(bot, 'setMessageReaction', params,
1007
- { source: 'status-reaction', botName: BOT_NAME });
1002
+ // rc.16: mirror the reaction onto album siblings too, so a multi-file
1003
+ // send shows the same status emoji on EVERY item, not just the anchor.
1004
+ // For a normal single message, _albumSiblingMsgIds is undefined and this
1005
+ // is exactly the prior single setMessageReaction. Anchor is awaited
1006
+ // (failure surfaces to the reactor); siblings are best-effort.
1007
+ await applyReactionToMessages({
1008
+ tg, bot, chatId,
1009
+ msgIds: [msg.message_id, ...(msg._albumSiblingMsgIds || [])],
1010
+ emoji,
1011
+ botName: BOT_NAME,
1012
+ });
1008
1013
  },
1009
1014
  availableEmojis,
1010
1015
  logError: (m) => console.error(`[${label}] ${m}`),
@@ -1892,6 +1897,10 @@ function createBot(token) {
1892
1897
  }
1893
1898
 
1894
1899
  const synthetic = { ...primary, _mergedAttachments: merged };
1900
+ // rc.16: carry the album sibling msg_ids so the status reactor can mirror
1901
+ // its emoji onto every item (not just the anchor) — see the reactor
1902
+ // `apply` closure + lib/telegram/album-reactions.js.
1903
+ if (siblingMsgIds.length) synthetic._albumSiblingMsgIds = siblingMsgIds;
1895
1904
  // Carry the primary's text verbatim (dispatchRegularMessage re-cleans
1896
1905
  // the mention). Caption → text so downstream sees it uniformly.
1897
1906
  if (!synthetic.text && synthetic.caption) synthetic.text = synthetic.caption;