polygram 0.11.0-rc.2 → 0.11.0-rc.3

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.11.0-rc.2",
4
+ "version": "0.11.0-rc.3",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.11.0-rc.2",
3
+ "version": "0.11.0-rc.3",
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
@@ -476,6 +476,31 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
476
476
  const chatConfig = config.chats[chatId];
477
477
  const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
478
478
  const maxTurnMs = (chatConfig.maxTurn || config.defaults?.maxTurn || 1800) * 1000;
479
+
480
+ // ChannelsProcess-only liveness heartbeat. Lazy-attached HERE (after
481
+ // getOrSpawnForChat) so handleMessage stays fast for slash commands and
482
+ // any other non-pm.send paths — earlier wiring at handleMessage:~1003
483
+ // forced a cold-spawn (~30s on channels) before THINKING reactor /
484
+ // typing indicator / autosteer decision, hiding user feedback. Now the
485
+ // reactor only exists when we genuinely need a turn.
486
+ //
487
+ // Gated on entry.backend === 'channels' AND context.sourceMsgId (the
488
+ // TG user-msg to react on). Non-msg callers (boot-replay,
489
+ // autonomous-wakeup re-dispatch) pass no sourceMsgId and skip the
490
+ // reactor entirely.
491
+ let heartbeatReactor = null;
492
+ if (entry.backend === 'channels'
493
+ && typeof context.heartbeatSetReaction === 'function'
494
+ && context.sourceMsgId != null) {
495
+ heartbeatReactor = new HeartbeatReactor({
496
+ process: entry,
497
+ chatId,
498
+ messageId: context.sourceMsgId,
499
+ setReaction: context.heartbeatSetReaction,
500
+ logger: { debug: () => {}, warn: (m) => console.warn(`[${sessionKey}] ${m}`) },
501
+ });
502
+ }
503
+
479
504
  // Hold the per-session lock across the FULL turn (write + result wait),
480
505
  // not just the stdin write. Claude's stream-json input mode batches any
481
506
  // user messages that arrive while a turn is in flight into the next
@@ -504,6 +529,10 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
504
529
  return await turnP;
505
530
  } finally {
506
531
  release();
532
+ // Belt-and-braces stop. The reactor auto-stops on idle/close/
533
+ // bridge-disconnected events from the Process, so this is idempotent
534
+ // — but it also covers the "send threw before any event fired" path.
535
+ heartbeatReactor?.stop();
507
536
  }
508
537
  }
509
538
 
@@ -1001,35 +1030,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1001
1030
  });
1002
1031
  },
1003
1032
  });
1004
- // ChannelsProcess-only liveness heartbeat. SDK + tmux backends have
1005
- // per-tool reaction visibility via the JSONL stream / SDK callbacks —
1006
- // the symbolic `reactor` above handles them. Channels intentionally
1007
- // hides mid-turn tool calls behind the bridge MCP protocol, so we
1008
- // substitute a time-driven cycling heartbeat on the same user message.
1009
- //
1010
- // We instantiate before pm.send fires so the reactor is bound to the
1011
- // Process *before* the first 'thinking' emission. getOrSpawnForChat
1012
- // is idempotent — the inner sendToProcess call will see the same
1013
- // entry. Stop is in the finally below, mirroring the symbolic reactor.
1014
- let heartbeatReactor = null;
1015
- if (pickBackend({ config, chatId, threadId: threadId || null }) === 'channels') {
1016
- const entry = await getOrSpawnForChat(sessionKey);
1017
- if (entry && typeof entry.on === 'function') {
1018
- heartbeatReactor = new HeartbeatReactor({
1019
- process: entry,
1020
- chatId,
1021
- messageId: msg.message_id,
1022
- setReaction: async (cid, mid, emoji) => {
1023
- await tg(bot, 'setMessageReaction', {
1024
- chat_id: cid,
1025
- message_id: mid,
1026
- reaction: emoji.length ? [{ type: 'emoji', emoji: emoji[0] }] : [],
1027
- }, { source: 'channels-heartbeat', botName: BOT_NAME }).catch(() => {});
1028
- },
1029
- logger: { debug: () => {}, warn: (m) => console.warn(`[${label}] ${m}`) },
1030
- });
1031
- }
1032
- }
1033
+ // Channels-only heartbeat setReaction adapter. Plumbed into sendToProcess
1034
+ // via context; sendToProcess instantiates the actual HeartbeatReactor
1035
+ // lazily after getOrSpawnForChat returns (rc.3: see sendToProcess body
1036
+ // for why we no longer construct here). Closure over `bot` keeps the
1037
+ // tg() dependency local.
1038
+ const heartbeatSetReaction = async (cid, mid, emoji) => {
1039
+ await tg(bot, 'setMessageReaction', {
1040
+ chat_id: cid,
1041
+ message_id: mid,
1042
+ reaction: emoji.length ? [{ type: 'emoji', emoji: emoji[0] }] : [],
1043
+ }, { source: 'channels-heartbeat', botName: BOT_NAME }).catch(() => {});
1044
+ };
1033
1045
 
1034
1046
  // rc.32: skip QUEUED (👀) entirely for first-message-in-chain. Go
1035
1047
  // straight to THINKING (🤔). The 👀 → 🤔 two-hop didn't add
@@ -1096,6 +1108,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1096
1108
  await new Promise((dispatched) => {
1097
1109
  sendPromise = sendToProcess(sessionKey, prompt, {
1098
1110
  streamer, reactor, sourceMsgId: msg.message_id,
1111
+ heartbeatSetReaction,
1099
1112
  // 0.7.4 (item B): fire THINKING when Claude actually starts
1100
1113
  // emitting — not the moment we wrote stdin.
1101
1114
  onFirstStream: () => reactor.setState('THINKING'),
@@ -1115,11 +1128,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1115
1128
  // AUTOSTEERED is terminal; stop the reactor's STALL / TIMEOUT
1116
1129
  // timers so they don't pin the closure for up to 30s.
1117
1130
  reactor.stop();
1118
- // Channels-only: stop the cycling-emoji heartbeat too. (When autosteer
1119
- // gets implemented for channels, the in-flight turn's own heartbeat
1120
- // will continue ticking on its primary msg-id; this msg's heartbeat
1121
- // stops here because its turn was folded into the primary's.)
1122
- heartbeatReactor?.stop();
1131
+ // No channels-heartbeat stop here autosteer skips sendToProcess
1132
+ // entirely, so no HeartbeatReactor was constructed.
1123
1133
  markReplied();
1124
1134
  return;
1125
1135
  }
@@ -1620,10 +1630,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1620
1630
  } finally {
1621
1631
  stopTyping();
1622
1632
  reactor.stop();
1623
- // Channels-only: stop the cycling-emoji heartbeat. Idempotent — second
1624
- // stop is a no-op (the reactor's `stopped` flag short-circuits). Covers
1625
- // every exit path (success, throw, abort, timeout).
1626
- heartbeatReactor?.stop();
1633
+ // HeartbeatReactor (channels-only) is stopped inside sendToProcess's
1634
+ // own finally block no handleMessage-level stop needed.
1627
1635
  // rc.38: defensive clear-on-exit for ✍ reactions. Pre-rc.38 only
1628
1636
  // the success path (line ~2622), the abort path (line ~2858), and
1629
1637
  // the tool-only-completion path (line ~2681) cleared