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.
- package/lib/process/cli-process.js +64 -12
- package/lib/telegram/album-reactions.js +50 -0
- package/package.json +1 -1
- package/polygram.js +16 -7
|
@@ -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:
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
|
|
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 (
|
|
1745
|
-
|
|
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.
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
await
|
|
1007
|
-
|
|
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;
|