polygram 0.10.0-rc.21 → 0.10.0-rc.23

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.21",
4
+ "version": "0.10.0-rc.23",
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",
@@ -41,13 +41,37 @@ function createHandleAbort({
41
41
 
42
42
  const threadId = msg.message_thread_id?.toString();
43
43
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
44
- const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
44
+ const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
45
+ const hadActive = !!proc?.inFlight;
45
46
 
46
47
  // Mark BEFORE killing: the 'close' event fires almost immediately
47
48
  // after interrupt, and the surrounding handleMessage's catch
48
49
  // needs to see the flag to skip the generic error-reply.
49
50
  if (hadActive) markSessionAborted(sessionKey);
50
51
 
52
+ // Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
53
+ // looked at an in-flight TURN. But the agent can leave a DETACHED
54
+ // background shell running (a `run_in_background:true` Bash) that
55
+ // outlives the turn; the tmux TUI shows an `N shell` indicator.
56
+ // When there is no live turn, check for such a shell and stop it
57
+ // so "Stop" acts truthfully instead of replying "Nothing to stop"
58
+ // while work is still churning. tmux-only — the SDK Process has no
59
+ // hasBackgroundShell()/killBackgroundShells(); the typeof guards
60
+ // make this a no-op there.
61
+ let killedBackgroundShell = false;
62
+ if (!hadActive && proc
63
+ && typeof proc.hasBackgroundShell === 'function'
64
+ && typeof proc.killBackgroundShells === 'function') {
65
+ try {
66
+ if (await proc.hasBackgroundShell()) {
67
+ markSessionAborted(sessionKey);
68
+ killedBackgroundShell = await proc.killBackgroundShells();
69
+ }
70
+ } catch (err) {
71
+ logger.error?.(`[${botName}] background-shell stop failed: ${err.message}`);
72
+ }
73
+ }
74
+
51
75
  // SDK abort: interrupt() + drainQueue(). interrupt() cancels
52
76
  // the in-flight turn at SDK level WITHOUT tearing down the
53
77
  // Query (cheap to reuse for the user's next message);
@@ -62,6 +86,7 @@ function createHandleAbort({
62
86
  logEvent('abort-requested', {
63
87
  chat_id: chatId, user_id: msg.from?.id || null,
64
88
  had_active: hadActive,
89
+ killed_background_shell: killedBackgroundShell,
65
90
  trigger: cleanText.slice(0, 40),
66
91
  });
67
92
 
@@ -69,10 +94,23 @@ function createHandleAbort({
69
94
  // detection is crude but reliable for ru/en.
70
95
  const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
71
96
  const strs = {
72
- en: { stopped: 'Stopped.', nothing: 'Nothing to stop.' },
73
- ru: { stopped: 'Остановлено.', nothing: 'Нечего останавливать.' },
97
+ en: {
98
+ stopped: 'Stopped.',
99
+ bgStopped: 'Stopped the background task.',
100
+ nothing: 'Nothing to stop.',
101
+ },
102
+ ru: {
103
+ stopped: 'Остановлено.',
104
+ bgStopped: 'Фоновая задача остановлена.',
105
+ nothing: 'Нечего останавливать.',
106
+ },
74
107
  }[lang];
75
- const reply = hadActive ? strs.stopped : strs.nothing;
108
+ // Truthful ack: a stopped in-flight turn → "Stopped"; a stopped
109
+ // background shell → "Stopped the background task"; neither →
110
+ // "Nothing to stop".
111
+ const reply = hadActive ? strs.stopped
112
+ : killedBackgroundShell ? strs.bgStopped
113
+ : strs.nothing;
76
114
  try {
77
115
  await tg(bot, 'sendMessage', {
78
116
  chat_id: chatId, text: reply,
@@ -88,6 +88,16 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
88
88
  const READY_HINTS_RE = /\?\s+for shortcuts|accept edits on|bypass permissions on/;
89
89
  const STREAMING_HINT_RE = /esc to interrupt/;
90
90
 
91
+ // Bug 1 (incident 2026-05-18): when the agent leaves a detached
92
+ // background shell running (a `run_in_background:true` Bash), the
93
+ // claude TUI shows a background-shell count in the pane. Verified
94
+ // against claude 2.1.142 — two forms:
95
+ // - the bottom hint line: "… · 1 shell · ↓ to manage"
96
+ // - the status line: "✻ Baked for 5s · 1 shell still running"
97
+ // Both carry "<N> shell(s)". polygram's turn-scoped Stop is blind to
98
+ // these; this regex lets the abort handler see them.
99
+ const BG_SHELL_RE = /\b\d+\s+shells?\b/;
100
+
91
101
  // L1 fix (spike leftover): the claude TUI shows its welcome banner
92
102
  // WITH a ready hint at the bottom during startup — before the user's
93
103
  // prompt has been processed:
@@ -135,7 +145,17 @@ const TUI_BANNER_RE = /▐▛███▜▌|▝▜█████▛▘/;
135
145
  // The optional `❯` cursor in [^\S\n]*(?:❯[^\S\n]+)?1\. is still
136
146
  // bounded to the line containing `1.`, so the security property
137
147
  // holds — only a real menu line satisfies it.
138
- const APPROVAL_PROMPT_RE = /Do you want to (?:proceed|do this|continue)\??[\s\S]{0,400}?(?:^|\n)[^\S\n]*(?:❯[^\S\n]+)?1\.\s+/im;
148
+ //
149
+ // 2026-05-18 incident fix: the verb after "Do you want to" varies by
150
+ // tool — Bash → "do this", Write → "create CLAUDE.md", Edit → "make
151
+ // this edit", etc. A `proceed|do this|continue` whitelist missed
152
+ // "create" and hung the Music topic for 7+ min with no approval card.
153
+ // Match the STRUCTURE, not a verb whitelist: a "Do you want to …?"
154
+ // question (verb is a bounded wildcard, single-line — no newline so
155
+ // it can't swallow past the question) followed within the bounded
156
+ // window by the numbered menu. The verb was never the security
157
+ // control — the required `1.` menu line is, and it is unchanged.
158
+ const APPROVAL_PROMPT_RE = /Do you want to [^\n?]{1,80}\??[\s\S]{0,400}?(?:^|\n)[^\S\n]*(?:❯[^\S\n]+)?1\.\s+/im;
139
159
  // Pull the tool name + raw arg snippet from the line preceding the
140
160
  // approval prompt. Capture-pane preserves the ⏺ marker.
141
161
  const TOOL_INVOCATION_RE = /⏺\s+([A-Za-z_]\w*)\s*\((.*?)\)\s*$/m;
@@ -532,12 +552,26 @@ class TmuxProcess extends Process {
532
552
  // Internal turn-done signal — settled by _flushActiveGroup when
533
553
  // this turn's group is flushed on a terminal `result`.
534
554
  turn.resultPromise = new Promise((resolve) => { turn.settleResult = resolve; });
555
+ // Bug 3: interrupt signal. `interrupt()` settles `signalInterrupt`
556
+ // to end this turn's race promptly — without it, an interrupted
557
+ // turn whose tool was killed by C-c writes no JSONL `result` and
558
+ // shows no capture-pane completion the race recognises, so
559
+ // `_runTurn` would hang until the absolute `turnTimeoutMs`.
560
+ turn.interruptP = new Promise((resolve) => { turn.signalInterrupt = resolve; });
535
561
 
536
562
  try {
537
563
  // rc.13.1: pasteAndEnter holds a per-session async lock around
538
564
  // paste + Enter so a concurrent injectUserMessage paste cannot
539
565
  // interleave keystrokes with this primary prompt.
540
- const result = await this._pasteAndEnter(this._embedToken(turn.prompt, turn.token));
566
+ //
567
+ // confirmSubmit: this is a PRIMARY turn pasting into an idle TUI
568
+ // (the previous turn finished before _runTurn drained this one).
569
+ // A production polygram prompt is ~1-2KB → the TUI collapses it
570
+ // into a bracketed-paste block whose single Enter can be
571
+ // absorbed (2026-05-18 incident). confirmSubmit re-sends Enter
572
+ // until the input box clears, or fails loud.
573
+ const result = await this._pasteAndEnter(
574
+ this._embedToken(turn.prompt, turn.token), { confirmSubmit: true });
541
575
  if (result.stripped > 0) {
542
576
  this.logger.warn?.(
543
577
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
@@ -596,6 +630,7 @@ class TmuxProcess extends Process {
596
630
  turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
597
631
  captureRaceP,
598
632
  turnDeadlineP,
633
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
599
634
  ]);
600
635
 
601
636
  // If capture-pane won but the turn used a tool, the agent is
@@ -603,10 +638,14 @@ class TmuxProcess extends Process {
603
638
  // tool calls. Wait for the real terminal result from JSONL, but
604
639
  // keep the absolute deadline armed so a JSONL `result` that
605
640
  // never arrives still fails the turn rather than hanging it.
641
+ // The interrupt signal still wins here too — Bug 3: an
642
+ // interrupted tool turn writes no terminal JSONL `result`, so
643
+ // without this racer it would hang to `turnTimeoutMs`.
606
644
  if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
607
645
  winner = await Promise.race([
608
646
  turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
609
647
  turnDeadlineP,
648
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
610
649
  ]);
611
650
  }
612
651
  } finally {
@@ -620,7 +659,18 @@ class TmuxProcess extends Process {
620
659
  let text;
621
660
  let resultSubtype = 'success';
622
661
  let stopReason = null;
623
- if (winner.kind === 'jsonl') {
662
+ if (winner.kind === 'interrupt') {
663
+ // Bug 3: `interrupt()` ended the turn. C-c was sent to the
664
+ // TUI; the turn stops here instead of hanging until the
665
+ // absolute `turnTimeoutMs`. Deliver whatever partial text the
666
+ // agent streamed before the interrupt (may be empty) with an
667
+ // explicit `interrupted` subtype so polygram's caller can tell
668
+ // a stopped turn apart from a clean completion.
669
+ turn.interrupted = true;
670
+ text = turn.text || '';
671
+ resultSubtype = 'interrupted';
672
+ stopReason = 'interrupted';
673
+ } else if (winner.kind === 'jsonl') {
624
674
  text = turn.text || winner.ev.text || '';
625
675
  resultSubtype = winner.ev.subtype || 'success';
626
676
  stopReason = winner.ev.stopReason || null;
@@ -796,6 +846,10 @@ class TmuxProcess extends Process {
796
846
  startedAt: 0,
797
847
  resolve: null, reject: null, callerPromise: null,
798
848
  settleResult: null, resultPromise: null,
849
+ // Bug 3: settled by `interrupt()` to make a live turn's
850
+ // `_runTurn` race end promptly instead of hanging until
851
+ // `turnTimeoutMs`. Armed at the top of `_runTurn`.
852
+ signalInterrupt: null, interruptP: null, interrupted: false,
799
853
  };
800
854
  }
801
855
 
@@ -1237,13 +1291,18 @@ class TmuxProcess extends Process {
1237
1291
  * asynchronously (on confirm/timeout) so it gates only the NEXT
1238
1292
  * paste, never delays this caller.
1239
1293
  */
1240
- async _pasteAndEnter(text) {
1294
+ async _pasteAndEnter(text, { confirmSubmit = false } = {}) {
1241
1295
  const token = this._extractTokens(text)[0] || null;
1242
1296
  const release = await this._pasteLock.acquire(this.tmuxName);
1243
1297
  let result;
1244
1298
  try {
1245
1299
  if (typeof this.runner.pasteAndEnter === 'function') {
1246
- result = await this.runner.pasteAndEnter(this.tmuxName, text);
1300
+ // 2026-05-18 incident: confirmSubmit re-sends Enter if a large
1301
+ // bracketed paste didn't submit. ONLY for a primary turn's
1302
+ // paste into an idle TUI — never for an autosteer paste into a
1303
+ // mid-turn TUI (the TUI parks that in its own queue; the input
1304
+ // box holding it is expected, not a failed submit).
1305
+ result = await this.runner.pasteAndEnter(this.tmuxName, text, { confirmSubmit });
1247
1306
  } else {
1248
1307
  result = await this.runner.pasteText(this.tmuxName, text);
1249
1308
  await this.runner.sendControl(this.tmuxName, 'Enter');
@@ -1508,10 +1567,78 @@ class TmuxProcess extends Process {
1508
1567
  this.logger.error?.(`[${this.label}] interrupt: ${err.message}`);
1509
1568
  return false;
1510
1569
  }
1570
+ // Bug 3: C-c stops the agent's work in the TUI, but an interrupted
1571
+ // turn (especially a tool turn) writes no terminal JSONL `result`
1572
+ // and shows no capture-pane completion `_runTurn`'s race
1573
+ // recognises — so `_runTurn` would hang until the absolute
1574
+ // `turnTimeoutMs`. Settle the running turn's interrupt signal so
1575
+ // its race ends NOW. The running primary turn is `pendingQueue[0]`
1576
+ // in state 'pasted'/'streaming'.
1577
+ const running = this.pendingQueue.find(
1578
+ (t) => t.state === 'pasted' || t.state === 'streaming',
1579
+ );
1580
+ if (running && typeof running.signalInterrupt === 'function') {
1581
+ running.signalInterrupt();
1582
+ }
1511
1583
  this.emit('interrupt-applied', { backend: 'tmux' });
1512
1584
  return true;
1513
1585
  }
1514
1586
 
1587
+ /**
1588
+ * Bug 1: report whether the TUI currently shows a running
1589
+ * background shell (a detached `run_in_background:true` Bash). This
1590
+ * is work that outlives the turn — polygram's turn-scoped Stop is
1591
+ * blind to it. Reads the pane bottom for the `N shell` indicator.
1592
+ * @returns {Promise<boolean>}
1593
+ */
1594
+ async hasBackgroundShell() {
1595
+ if (this.closed) return false;
1596
+ try {
1597
+ const buf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
1598
+ // The indicator lives in the bottom few lines of the pane.
1599
+ return BG_SHELL_RE.test(String(buf || '').slice(-2000));
1600
+ } catch (err) {
1601
+ this.logger.error?.(`[${this.label}] hasBackgroundShell: ${err.message}`);
1602
+ return false;
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * Bug 1: stop every running background shell via the TUI's
1608
+ * background-task panel. Sequence verified against claude 2.1.142:
1609
+ * `/bashes` + Enter opens the "Shell details" panel (legend
1610
+ * "Esc/Enter/Space to close · x to stop"); `x` stops the shell;
1611
+ * Esc closes the panel. Repeats while a shell remains, bounded so a
1612
+ * stuck panel can't loop forever.
1613
+ *
1614
+ * @returns {Promise<boolean>} true if no background shell remains
1615
+ * after the attempt (all stopped, or none was running).
1616
+ */
1617
+ async killBackgroundShells() {
1618
+ if (this.closed) return false;
1619
+ const maxRounds = 8; // bound — one round per shell, plus slack
1620
+ for (let round = 0; round < maxRounds; round += 1) {
1621
+ if (!(await this.hasBackgroundShell())) return true;
1622
+ try {
1623
+ // Open the background-task panel.
1624
+ await this.runner.pasteText(this.tmuxName, '/bashes');
1625
+ await this.runner.sendControl(this.tmuxName, 'Enter');
1626
+ await this._sleep(this.pollMs * 4 + 200);
1627
+ // Stop the shell shown in the Shell-details panel.
1628
+ await this.runner.sendControl(this.tmuxName, 'x');
1629
+ await this._sleep(this.pollMs * 4 + 200);
1630
+ // Close the panel.
1631
+ await this.runner.sendControl(this.tmuxName, 'Escape');
1632
+ await this._sleep(this.pollMs * 2 + 100);
1633
+ } catch (err) {
1634
+ this.logger.error?.(`[${this.label}] killBackgroundShells: ${err.message}`);
1635
+ return false;
1636
+ }
1637
+ }
1638
+ // Bounded out — report the residual state honestly.
1639
+ return !(await this.hasBackgroundShell());
1640
+ }
1641
+
1515
1642
  async setModel(model) {
1516
1643
  if (this.closed || !model) return false;
1517
1644
  try {
@@ -46,6 +46,62 @@ const MULTILINE_SEPARATOR = ' / ';
46
46
  // via MULTILINE_SEPARATOR.
47
47
  const CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
48
48
 
49
+ // 2026-05-18 incident: a ~1.2KB polygram prompt is collapsed by the
50
+ // claude TUI into a bracketed-paste block rendered as "[Pasted text
51
+ // #1]". A single Enter sent after the fixed paste drain does NOT
52
+ // submit it — the Enter is absorbed while the TUI is still ingesting
53
+ // the block, so the prompt sits unsubmitted in the input box and the
54
+ // turn never starts. `pasteAndEnter` confirms the submit landed by
55
+ // capture-pane and re-sends Enter if it didn't.
56
+
57
+ // The claude TUI draws its input box bracketed by horizontal-rule
58
+ // lines (long runs of the box-drawing char `─`). The input prompt is
59
+ // a `❯`-prefixed line BETWEEN those rules. CRUCIALLY, a SUBMITTED
60
+ // message is ALSO echoed with a `❯` prefix up in the conversation
61
+ // area — so a naive "any ❯ line with paste content" check
62
+ // false-positives on the echo of a just-submitted prompt. The input
63
+ // box must be identified structurally: it is the `❯` line adjacent
64
+ // to a horizontal rule.
65
+ const TUI_RULE_RE = /^[^\S\n]*─{20,}[^\S\n]*$/;
66
+
67
+ // Markers that mean "this line is holding an un-submitted paste":
68
+ // the bracketed-paste collapse marker, or a polygram-prompt opening
69
+ // tag (polygram only ever sends those as a paste body).
70
+ const HELD_PASTE_RE = /\[Pasted text|<polygram-info|<session-context|<channel\b/;
71
+
72
+ /**
73
+ * 2026-05-18 incident detector. Returns true when the claude TUI's
74
+ * INPUT BOX still holds an un-submitted paste — i.e. the prompt was
75
+ * pasted but the Enter did not submit it.
76
+ *
77
+ * Identifies the input box structurally: the `❯`-prefixed line that
78
+ * is adjacent to a horizontal-rule line. That distinguishes the live
79
+ * input box from the `❯`-prefixed echo of an already-submitted
80
+ * message in the conversation area (which would otherwise
81
+ * false-positive). An empty input box (`❯ ` with nothing after it)
82
+ * returns false — that is a clean, submitted state.
83
+ */
84
+ function inputBoxHoldsPaste(pane) {
85
+ const lines = String(pane || '').split('\n');
86
+ for (let i = 0; i < lines.length; i += 1) {
87
+ const m = lines[i].match(/^[^\S\n]*❯[^\S\n]?(.*)$/);
88
+ if (!m) continue;
89
+ // Adjacent to a horizontal rule above or below ⇒ this is the
90
+ // input box, not a submitted-message echo.
91
+ const ruleAbove = i > 0 && TUI_RULE_RE.test(lines[i - 1]);
92
+ const ruleBelow = i + 1 < lines.length && TUI_RULE_RE.test(lines[i + 1]);
93
+ if (!ruleAbove && !ruleBelow) continue;
94
+ if (HELD_PASTE_RE.test(m[1])) return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ // Submit-confirmation defaults — overridable per construction for
100
+ // tests. `retries` extra Enter presses after the first; `pollMs` the
101
+ // settle wait before each capture-pane check; `drainMs` the wait
102
+ // after a re-sent Enter before re-checking.
103
+ const DEFAULT_SUBMIT_CONFIRM = { retries: 4, pollMs: 200, drainMs: 250 };
104
+
49
105
  // ─── execFile wrapper ────────────────────────────────────────────────
50
106
 
51
107
  /**
@@ -139,8 +195,15 @@ function ensureLogDir(logPath) {
139
195
  * @param {object} [opts.logger=console]
140
196
  * @param {Function} [opts.runFn] — override the underlying execFile
141
197
  * wrapper (for tests). Same signature: (cmd, args, opts?) → Promise.
198
+ * @param {object} [opts.submitConfirm] — large-paste submit-confirm
199
+ * tunables { retries, pollMs, drainMs }; tests shorten these.
142
200
  */
143
- function createTmuxRunner({ logger = console, runFn = run } = {}) {
201
+ function createTmuxRunner({
202
+ logger = console,
203
+ runFn = run,
204
+ submitConfirm = {},
205
+ } = {}) {
206
+ const submitCfg = { ...DEFAULT_SUBMIT_CONFIRM, ...submitConfirm };
144
207
 
145
208
  async function spawn({
146
209
  name,
@@ -212,7 +275,24 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
212
275
  // sessions don't block each other. Within one session, pasteText
213
276
  // + sendControl(Enter) hold the lock atomically.
214
277
  const inputLock = createAsyncLock();
215
- async function pasteAndEnter(name, text) {
278
+ /**
279
+ * Paste a prompt body + press Enter, atomically per session.
280
+ *
281
+ * @param {string} name tmux session
282
+ * @param {string} text prompt body
283
+ * @param {object} [opts]
284
+ * @param {boolean} [opts.confirmSubmit=false] — when true, CONFIRM
285
+ * the Enter actually submitted the prompt (capture-pane the input
286
+ * box, re-send Enter if it still holds the paste, throw if it
287
+ * never clears). Use ONLY for a primary turn's paste into an
288
+ * IDLE TUI — the 2026-05-18 large-bracketed-paste bug. Must be
289
+ * FALSE for an autosteer paste into a BUSY (mid-turn) TUI: there
290
+ * the TUI legitimately parks the paste in its own input queue
291
+ * (`queue-operation enqueue`), so the input box holding the paste
292
+ * is EXPECTED, not a failed submit — re-sending Enter into a
293
+ * streaming TUI would corrupt the queue.
294
+ */
295
+ async function pasteAndEnter(name, text, { confirmSubmit = false } = {}) {
216
296
  const release = await inputLock.acquire(name);
217
297
  try {
218
298
  const res = await pasteText(name, text);
@@ -226,7 +306,46 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
226
306
  // Enter's processing. 50ms is enough on the TUI we tested
227
307
  // against (claude v2.1.142); see AGENTS.md pinned version.
228
308
  await new Promise((r) => setTimeout(r, 50));
229
- return res;
309
+
310
+ // 2026-05-18 incident: a large (~1.2KB) bracketed paste is NOT
311
+ // submitted by that single Enter — the Enter is absorbed while
312
+ // the TUI is still ingesting the "[Pasted text #1]" block. The
313
+ // fixed drain is a timing guess that fails for big pastes.
314
+ // CONFIRM the submit landed by capture-pane; if the input box
315
+ // still holds the paste, re-send Enter (bounded retries). If it
316
+ // never clears, throw — pasteAndEnter must not report success
317
+ // for an unsubmitted prompt (a fake THINKING state otherwise).
318
+ //
319
+ // ONLY for a primary paste into an idle TUI (confirmSubmit). An
320
+ // autosteer paste into a mid-turn TUI is DELIBERATELY parked in
321
+ // the TUI's input queue — the input box holding it is correct,
322
+ // not a failure; re-sending Enter there would corrupt the queue.
323
+ if (!confirmSubmit) return res;
324
+ for (let attempt = 0; attempt <= submitCfg.retries; attempt += 1) {
325
+ await new Promise((r) => setTimeout(r, submitCfg.pollMs));
326
+ let pane;
327
+ try {
328
+ pane = await capturePane(name, { lines: 60 });
329
+ } catch (err) {
330
+ // capture-pane itself failed — can't confirm. Don't claim
331
+ // success; surface it.
332
+ throw Object.assign(
333
+ new Error(`pasteAndEnter: submit confirmation failed: ${err.message}`),
334
+ { code: 'TMUX_SUBMIT_FAILED', cause: err },
335
+ );
336
+ }
337
+ if (!inputBoxHoldsPaste(pane)) return res; // submitted ✓
338
+ if (attempt === submitCfg.retries) break; // out of retries
339
+ // Still stuck — the paste sits in the input box. Re-send
340
+ // Enter and give the TUI a moment before re-checking.
341
+ logger.debug?.(`[tmux-runner] ${name}: paste not submitted, re-sending Enter (attempt ${attempt + 1})`);
342
+ await sendControl(name, 'Enter');
343
+ await new Promise((r) => setTimeout(r, submitCfg.drainMs));
344
+ }
345
+ throw Object.assign(
346
+ new Error(`pasteAndEnter: prompt never submitted after ${submitCfg.retries + 1} Enter attempts`),
347
+ { code: 'TMUX_SUBMIT_FAILED', tmuxName: name },
348
+ );
230
349
  } finally {
231
350
  release();
232
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.21",
3
+ "version": "0.10.0-rc.23",
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
@@ -1379,6 +1379,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1379
1379
  await sendInlineStickers();
1380
1380
  await sendInlineReactions();
1381
1381
  await cleanupArchivedBubbles();
1382
+ // Bug 2 (incident 2026-05-18): this streamed-success branch
1383
+ // returns BEFORE the rc.10 deferred-clear block at the
1384
+ // bottom of the handler — so a turn that streamed its reply
1385
+ // never cleared the reactor. If the turn went quiet
1386
+ // mid-stream long enough to trip STALL (🥱), the emoji
1387
+ // stuck. reactor.stop() in the finally only kills timers,
1388
+ // not the visible reaction. Clear here, mirroring the
1389
+ // rc.10 block — AFTER delivery so there's no visual gap.
1390
+ reactor.clear().catch(() => {});
1391
+ clearAutosteeredReactions(sessionKey).catch(() => {});
1382
1392
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1383
1393
  markReplied();
1384
1394
  return;
@@ -1426,6 +1436,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1426
1436
  await sendInlineStickers();
1427
1437
  await sendInlineReactions();
1428
1438
  await cleanupArchivedBubbles();
1439
+ // Bug 2 (incident 2026-05-18): same gap as the finalEditOk
1440
+ // branch above — this streamed-redeliver path returns before
1441
+ // the rc.10 deferred-clear block, so the reactor would stay
1442
+ // stuck. Clear it (and autosteered ✍) here, after delivery —
1443
+ // but ONLY on a clean delivery. When r.failed.length>0 the
1444
+ // ERROR state (😨) was set above as the "look here" signal
1445
+ // for the partial-delivery failure; clearing it would wipe
1446
+ // that signal, so leave the reactor as-is in that case.
1447
+ if (r.failed.length === 0) {
1448
+ reactor.clear().catch(() => {});
1449
+ }
1450
+ clearAutosteeredReactions(sessionKey).catch(() => {});
1429
1451
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1430
1452
  markReplied();
1431
1453
  return;