polygram 0.10.0-rc.22 → 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.22",
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",
@@ -563,7 +563,15 @@ class TmuxProcess extends Process {
563
563
  // rc.13.1: pasteAndEnter holds a per-session async lock around
564
564
  // paste + Enter so a concurrent injectUserMessage paste cannot
565
565
  // interleave keystrokes with this primary prompt.
566
- 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 });
567
575
  if (result.stripped > 0) {
568
576
  this.logger.warn?.(
569
577
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
@@ -1283,13 +1291,18 @@ class TmuxProcess extends Process {
1283
1291
  * asynchronously (on confirm/timeout) so it gates only the NEXT
1284
1292
  * paste, never delays this caller.
1285
1293
  */
1286
- async _pasteAndEnter(text) {
1294
+ async _pasteAndEnter(text, { confirmSubmit = false } = {}) {
1287
1295
  const token = this._extractTokens(text)[0] || null;
1288
1296
  const release = await this._pasteLock.acquire(this.tmuxName);
1289
1297
  let result;
1290
1298
  try {
1291
1299
  if (typeof this.runner.pasteAndEnter === 'function') {
1292
- 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 });
1293
1306
  } else {
1294
1307
  result = await this.runner.pasteText(this.tmuxName, text);
1295
1308
  await this.runner.sendControl(this.tmuxName, 'Enter');
@@ -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.22",
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": {