polygram 0.8.0-rc.36 → 0.8.0-rc.38

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.8.0-rc.36",
4
+ "version": "0.8.0-rc.38",
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",
@@ -91,10 +91,18 @@ function createAutosteerBuffer() {
91
91
  * non-empty output, with kind='autosteer-hook-drained'.
92
92
  * @param {string|null} [opts.chatId] — for the logEvent payload only.
93
93
  * @param {object} [opts.logger] — for error logging (must have .error).
94
+ * @param {(sessionKey: string, drainedCount: number) => void} [opts.onDrained]
95
+ * — fired AFTER the hook successfully injects additionalContext.
96
+ * rc.37 wires this to clearAutosteeredReactions so the ✍ reaction
97
+ * fades the moment the agent absorbs the follow-up — not at SDK
98
+ * turn-end, which under autosteer can stretch tens of minutes
99
+ * (one SDK turn keeps absorbing follow-ups via additionalContext
100
+ * and never emits result, so the old turn-end-only cleanup left
101
+ * ✍ stuck across many user messages).
94
102
  *
95
103
  * @returns {async () => Promise<HookJSONOutput>}
96
104
  */
97
- function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = null, logger = console } = {}) {
105
+ function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = null, logger = console, onDrained = null } = {}) {
98
106
  if (!buffer) throw new TypeError('buffer required');
99
107
  if (!sessionKey) throw new TypeError('sessionKey required');
100
108
  return async () => {
@@ -111,6 +119,22 @@ function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = n
111
119
  });
112
120
  } catch { /* logger errors must not break the hook */ }
113
121
  }
122
+ if (typeof onDrained === 'function') {
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
+ }
136
+ catch (err) { logger?.error?.(`[${sessionKey}] onDrained: ${err?.message || err}`); }
137
+ }
114
138
  return {
115
139
  continue: true,
116
140
  hookSpecificOutput: {
@@ -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
- if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId) {
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;
@@ -39,7 +39,13 @@ const STATES = {
39
39
  // All three emoji on Telegram's curated standard reaction list.
40
40
  THINKING: { label: 'thinking', chain: ['🤔'] },
41
41
  THINKING_DEEPER: { label: 'thinking-deeper', chain: ['🤨', '🤔'] },
42
- THINKING_DEEPEST: { label: 'thinking-deepest', chain: ['🧐', '🤓', '🤔'] },
42
+ // rc.37: 🧐 (face with monocle) is REACTION_INVALID for bots — only
43
+ // Telegram Premium users can pick arbitrary emoji; bots are limited
44
+ // to ~70 standard reactions and 🧐 isn't on the list. Production
45
+ // log: `reaction apply failed (THINKING_DEEPEST → 🧐): 400: Bad
46
+ // Request: REACTION_INVALID`. 🤓 (nerd face, intellectual focus) is
47
+ // on the list and reads as "deeper than skeptical-eyebrow".
48
+ THINKING_DEEPEST: { label: 'thinking-deepest', chain: ['🤓', '🤔'] },
43
49
  CODING: { label: 'coding', chain: ['👨‍💻', '✍', '🤔'] },
44
50
  WEB: { label: 'web', chain: ['⚡', '🔥', '🤔'] },
45
51
  TOOL: { label: 'tool', chain: ['🔥', '🤔'] },
@@ -224,8 +230,8 @@ function createReactionManager({
224
230
  };
225
231
 
226
232
  // rc.32: thinking-deepening cascade. When state is THINKING, schedule
227
- // auto-promotion at 8s (→ THINKING_DEEPER, 🤨) and 20s (→ THINKING_DEEPEST,
228
- // 🧐). Any other setState (CODING / TOOL / WEB / WRITING / terminal) clears
233
+ // auto-promotion at 12s (→ THINKING_DEEPER, 🤨) and 30s (→ THINKING_DEEPEST,
234
+ // 🤓). Any other setState (CODING / TOOL / WEB / WRITING / terminal) clears
229
235
  // these. heartbeat() does NOT re-arm them — heartbeat is for keeping
230
236
  // STALL/TIMEOUT at bay during silent activity, not for resetting
231
237
  // visible deepening progression.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.36",
3
+ "version": "0.8.0-rc.38",
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
@@ -931,6 +931,18 @@ function buildSdkOptions(sessionKey, ctx) {
931
931
  chatId: ctx?.chatId ?? null,
932
932
  logEvent,
933
933
  logger: console,
934
+ // rc.37: clear ✍ reactions when the hook ABSORBS follow-ups, not
935
+ // at SDK turn-end. Under autosteer one SDK "turn" can stretch
936
+ // tens of minutes — every drain feeds more user text via
937
+ // additionalContext, the agent keeps reasoning, no `result` event
938
+ // fires, inFlight stays true, ✍ stays stuck on every drained
939
+ // follow-up. Clearing at drain time matches user perception
940
+ // ("the bot got my message → ✍ goes away").
941
+ onDrained: (key) => {
942
+ clearAutosteeredReactions(key).catch((err) => {
943
+ console.error(`[${BOT_NAME}] autosteer-hook clearReactions: ${err.message}`);
944
+ });
945
+ },
934
946
  });
935
947
 
936
948
  // 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
@@ -2520,6 +2532,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2520
2532
  // applyChain — so it serializes after any in-flight
2521
2533
  // QUEUED apply and lands as the final visible reaction.
2522
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();
2523
2543
  markReplied();
2524
2544
  return;
2525
2545
  }
@@ -2856,6 +2876,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2856
2876
  } finally {
2857
2877
  stopTyping();
2858
2878
  reactor.stop();
2879
+ // rc.38: defensive clear-on-exit for ✍ reactions. Pre-rc.38 only
2880
+ // the success path (line ~2622), the abort path (line ~2858), and
2881
+ // the tool-only-completion path (line ~2681) cleared
2882
+ // autosteeredRefs. The plain error path (`if (result.error)` →
2883
+ // throw at ~2612), the empty-response fallback failure (~2714),
2884
+ // and the streamer-overflow path could all leave ✍ reactions
2885
+ // stuck on follow-ups whose buffer entries had never been
2886
+ // drained by PostToolBatch. The clear is idempotent (the second
2887
+ // call returns 0 against an already-emptied map) so adding it
2888
+ // here covers ALL exit paths without double-clearing harm.
2889
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2859
2890
  }
2860
2891
  }
2861
2892