polygram 0.10.0-rc.23 → 0.10.0-rc.25

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.23",
4
+ "version": "0.10.0-rc.25",
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",
@@ -210,6 +210,12 @@ class TmuxProcess extends Process {
210
210
  queueCap = 50, // P0.1 parity: SDK enforces queueCap=50 too
211
211
  pollScheduler = null, // O1 optimization: shared cross-process tick
212
212
  pasteConfirmMs = 2500, // Phase 3 §5: paste-gating JSONL-confirm timeout
213
+ // B7: a primary paste's submit is confirmed by its correlation
214
+ // token surfacing in a JSONL `user-message`. submitConfirmMs is the
215
+ // per-attempt wait for that line; submitConfirmRetries extra Enter
216
+ // presses before giving up loud.
217
+ submitConfirmMs = 1500,
218
+ submitConfirmRetries = 4,
213
219
  } = {}) {
214
220
  super({ sessionKey, chatId, threadId, label });
215
221
  if (!runner) throw new TypeError('TmuxProcess: runner required');
@@ -309,6 +315,24 @@ class TmuxProcess extends Process {
309
315
  this._pasteLock = createAsyncLock();
310
316
  this._pasteConfirms = new Map(); // token → resolve fn
311
317
  this.pasteConfirmMs = pasteConfirmMs;
318
+
319
+ // ─── B7: JSONL-token submit confirmation ──────────────────────
320
+ // shumorobot msgs 789 / 791 / 803: a large primary prompt collapses
321
+ // in the claude TUI into a `[Pasted text #N]` placeholder; the
322
+ // single post-paste Enter is absorbed mid-ingest and the prompt
323
+ // sits UNSUBMITTED — the turn never starts. B5 tried to confirm the
324
+ // submit by capture-pane, but the TUI renders the collapsed paste
325
+ // asynchronously, so capture-pane false-positives ("box looks
326
+ // clear → submitted ✓") on a transient frame. The ONLY reliable
327
+ // "the prompt reached claude" signal is the JSONL `user-message`
328
+ // line that reproduces THIS paste's correlation token verbatim.
329
+ // `_submitConfirms` maps token → resolve fn, fired ONLY by a
330
+ // `user-message` (NOT by `queue-operation enqueue` — an enqueue
331
+ // means the paste was parked in the TUI queue, not registered as a
332
+ // turn).
333
+ this._submitConfirms = new Map(); // token → resolve fn
334
+ this.submitConfirmMs = submitConfirmMs;
335
+ this.submitConfirmRetries = submitConfirmRetries;
312
336
  }
313
337
 
314
338
  get cost() { return 3; }
@@ -563,15 +587,8 @@ class TmuxProcess extends Process {
563
587
  // rc.13.1: pasteAndEnter holds a per-session async lock around
564
588
  // paste + Enter so a concurrent injectUserMessage paste cannot
565
589
  // interleave keystrokes with this primary prompt.
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
590
  const result = await this._pasteAndEnter(
574
- this._embedToken(turn.prompt, turn.token), { confirmSubmit: true });
591
+ this._embedToken(turn.prompt, turn.token));
575
592
  if (result.stripped > 0) {
576
593
  this.logger.warn?.(
577
594
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
@@ -579,6 +596,34 @@ class TmuxProcess extends Process {
579
596
  this.emit('prompt-sanitized', { stripped: result.stripped, source: 'send' });
580
597
  }
581
598
 
599
+ // B7 (shumorobot msgs 789/791/803): a PRIMARY turn pasting into
600
+ // an idle TUI MUST start a turn. A production polygram prompt is
601
+ // ~1-2KB → the claude TUI collapses it into a `[Pasted text #N]`
602
+ // placeholder whose single post-paste Enter can be absorbed
603
+ // mid-ingest, leaving the prompt UNSUBMITTED — the turn never
604
+ // starts. `_confirmSubmitViaJsonl` confirms the submit landed by
605
+ // waiting for this paste's correlation token to surface in a
606
+ // JSONL `user-message` (the ONLY reliable signal — capture-pane
607
+ // false-positives on the collapsed placeholder); it re-sends
608
+ // Enter on a miss and, after bounded retries, REJECTS with
609
+ // TMUX_SUBMIT_FAILED.
610
+ //
611
+ // The confirm drives TWO derived promises below:
612
+ // submitConfirmP — rejects TMUX_SUBMIT_FAILED → a racer that
613
+ // fails the turn fast and loud; on success it never settles.
614
+ // submitOkP — resolves ONLY once the submit is confirmed;
615
+ // on failure it never settles. It GATES the capture-pane
616
+ // racer: an "idle" capture-pane is meaningless until the turn
617
+ // has actually started — pre-gate, a `[Pasted text #N]`
618
+ // prompt sitting unsubmitted reads as an idle (=="complete")
619
+ // pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
620
+ // and mask the real TMUX_SUBMIT_FAILED cause.
621
+ const confirmP = turn.token
622
+ ? this._confirmSubmitViaJsonl(turn.token, turn)
623
+ : Promise.resolve(); // no token — nothing to confirm
624
+ const submitConfirmP = confirmP.then(() => new Promise(() => {}));
625
+ const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
626
+
582
627
  // R7: an ABSOLUTE timeout wrapping the whole race. The
583
628
  // capture-pane completion detector re-checks its deadline only
584
629
  // BETWEEN `captureWide` subprocess calls — if a single `tmux
@@ -617,11 +662,32 @@ class TmuxProcess extends Process {
617
662
  timeoutMs: turnTimeoutMs, abortP,
618
663
  });
619
664
  // The capture-pane loop may end via the absolute-deadline abort
620
- // (it returns the abort sentinel). Swallow that branch the
621
- // turnDeadlineP reject below is what actually fails the turn.
622
- const captureRaceP = captureCompleteP.then((buf) => (
623
- buf === ABORT_SENTINEL ? new Promise(() => {}) : { kind: 'capture' }
624
- ));
665
+ // (it returns the abort sentinel) or reject (its own timeout).
666
+ // Both are swallowed here the `turnDeadlineP` reject below is
667
+ // what actually fails the turn.
668
+ //
669
+ // B7: gate the capture racer behind `submitOkP`. A capture-pane
670
+ // "idle" reading only means "the turn completed" once the turn
671
+ // has actually STARTED. If the paste never submitted, the pane is
672
+ // idle because the prompt is still sitting in the input box — not
673
+ // because a turn finished. Without the gate the capture racer
674
+ // would win with TMUX_NO_JSONL_TEXT and mask the real
675
+ // TMUX_SUBMIT_FAILED. `captureCompleteP` is still awaited inside
676
+ // the racer (so its rejection is always handled — never orphaned
677
+ // into an unhandled rejection) and the poll loop / scheduler
678
+ // refcount lifecycle is unchanged; only the racer's eligibility
679
+ // to WIN is gated on submit confirmation.
680
+ const captureRaceP = (async () => {
681
+ let buf;
682
+ try {
683
+ buf = await captureCompleteP;
684
+ } catch {
685
+ return new Promise(() => {}); // capture's own timeout — turnDeadlineP fails the turn
686
+ }
687
+ if (buf === ABORT_SENTINEL) return new Promise(() => {});
688
+ await submitOkP; // gate: capture cannot win pre-submit-confirm
689
+ return { kind: 'capture' };
690
+ })();
625
691
 
626
692
  let resolvedVia = 'jsonl';
627
693
  let winner;
@@ -631,6 +697,8 @@ class TmuxProcess extends Process {
631
697
  captureRaceP,
632
698
  turnDeadlineP,
633
699
  turn.interruptP.then(() => ({ kind: 'interrupt' })),
700
+ // B7: TMUX_SUBMIT_FAILED rejection fails the turn fast.
701
+ submitConfirmP,
634
702
  ]);
635
703
 
636
704
  // If capture-pane won but the turn used a tool, the agent is
@@ -1151,6 +1219,10 @@ class TmuxProcess extends Process {
1151
1219
  // Phase 3 §5: a user-message proves its pastes landed — release
1152
1220
  // the paste gate for those tokens.
1153
1221
  this._confirmPaste(tokens);
1222
+ // B7: a user-message is the proof that a primary paste actually
1223
+ // STARTED a turn (claude registered the prompt). Release any
1224
+ // _confirmSubmitViaJsonl waiter for these tokens.
1225
+ this._confirmSubmit(tokens);
1154
1226
  let matched = [];
1155
1227
  for (const tok of tokens) {
1156
1228
  const t = this._ledger.find(
@@ -1166,7 +1238,15 @@ class TmuxProcess extends Process {
1166
1238
  const orphan = this._ledger.find(
1167
1239
  (x) => x.kind === 'primary' && x.state === 'pasted',
1168
1240
  );
1169
- if (orphan) matched = [orphan];
1241
+ if (orphan) {
1242
+ matched = [orphan];
1243
+ // B7: a token-less user-message claimed by the orphan
1244
+ // fallback still PROVES that primary's prompt started — so
1245
+ // release its submit-confirm waiter under the orphan's own
1246
+ // token (the `_confirmSubmit(tokens)` above could not, the
1247
+ // user-message carried no token to match).
1248
+ this._confirmSubmit([orphan.token]);
1249
+ }
1170
1250
  }
1171
1251
  if (matched.length === 0) {
1172
1252
  // Genuinely unrecognised — diagnostic, then ignore. (A
@@ -1290,19 +1370,33 @@ class TmuxProcess extends Process {
1290
1370
  * pastes can no longer concatenate. The lock is released
1291
1371
  * asynchronously (on confirm/timeout) so it gates only the NEXT
1292
1372
  * paste, never delays this caller.
1373
+ *
1374
+ * B7 (shumorobot msgs 789/791/803): `_pasteAndEnter` does NOT itself
1375
+ * confirm the paste SUBMITTED. A large prompt collapses in the claude
1376
+ * TUI into a `[Pasted text #N]` placeholder whose single post-paste
1377
+ * Enter can be absorbed mid-ingest, leaving the prompt unsubmitted —
1378
+ * but that submit-confirmation runs as a concurrent racer in
1379
+ * `_runTurn` (`_confirmSubmitViaJsonl`), NOT here. Blocking
1380
+ * `_pasteAndEnter` on the confirm would hold `_pasteLock` across the
1381
+ * whole confirm window and stall every following paste — an autosteer
1382
+ * that should fold into the primary turn could never paste.
1293
1383
  */
1294
- async _pasteAndEnter(text, { confirmSubmit = false } = {}) {
1384
+ async _pasteAndEnter(text) {
1295
1385
  const token = this._extractTokens(text)[0] || null;
1296
1386
  const release = await this._pasteLock.acquire(this.tmuxName);
1297
1387
  let result;
1298
1388
  try {
1389
+ // B7: the runner no longer does capture-pane submit-confirmation
1390
+ // (it false-positived on `[Pasted text #N]`). The runner just
1391
+ // pastes + Enter. Submit confirmation for a PRIMARY turn is
1392
+ // JSONL-token-based and runs as a CONCURRENT racer in `_runTurn`
1393
+ // (`_confirmSubmitViaJsonl`) — NOT here. Blocking `_pasteAndEnter`
1394
+ // on the confirm would hold `_pasteLock` across the whole confirm
1395
+ // window and stall every following paste (an autosteer that
1396
+ // SHOULD fold into the primary turn could never paste). The
1397
+ // confirm is a watchdog, not a paste-pipeline gate.
1299
1398
  if (typeof this.runner.pasteAndEnter === 'function') {
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 });
1399
+ result = await this.runner.pasteAndEnter(this.tmuxName, text);
1306
1400
  } else {
1307
1401
  result = await this.runner.pasteText(this.tmuxName, text);
1308
1402
  await this.runner.sendControl(this.tmuxName, 'Enter');
@@ -1321,6 +1415,91 @@ class TmuxProcess extends Process {
1321
1415
  return result;
1322
1416
  }
1323
1417
 
1418
+ /**
1419
+ * B7: confirm a primary paste actually submitted by waiting for its
1420
+ * correlation `token` to surface in a JSONL `user-message`. On each
1421
+ * miss, re-send Enter (the prior Enter was absorbed by the TUI's
1422
+ * bracketed-paste ingest of a `[Pasted text #N]` block). After
1423
+ * `submitConfirmRetries` exhausted misses, throw `TMUX_SUBMIT_FAILED`.
1424
+ *
1425
+ * The signal is the `user-message` ONLY — not `queue-operation`. A
1426
+ * `queue-operation enqueue` means the paste was parked in the TUI
1427
+ * queue, which for a primary paste into an idle TUI would itself be
1428
+ * wrong; the genuine "the prompt started a turn" proof is the
1429
+ * `user-message` claude writes when it registers the prompt.
1430
+ *
1431
+ * Runs as a concurrent racer in `_runTurn` (NOT a blocking gate in
1432
+ * `_pasteAndEnter` — that would hold `_pasteLock` across the confirm
1433
+ * window and stall a folding autosteer's paste). `turn` is the owning
1434
+ * Turn: if it reaches a terminal state (the real result/capture
1435
+ * racer already won, or the turn was killed) the retry loop bails so
1436
+ * a stray retry Enter cannot land in an unrelated turn.
1437
+ */
1438
+ async _confirmSubmitViaJsonl(token, turn = null) {
1439
+ for (let attempt = 0; attempt <= this.submitConfirmRetries; attempt += 1) {
1440
+ const confirmed = await this._awaitSubmitConfirm(token);
1441
+ if (confirmed) return; // submitted ✓
1442
+ // The turn already settled some other way (result/capture/kill)
1443
+ // — the submit clearly is no longer the open question. Stop:
1444
+ // re-sending Enter or throwing now would be wrong.
1445
+ if (turn && (turn.state === 'done' || turn.state === 'failed')) return;
1446
+ if (attempt === this.submitConfirmRetries) break; // out of retries
1447
+ // The tokened user-message never arrived — the prompt is still
1448
+ // sitting in the input box as `[Pasted text #N]`. Re-send Enter.
1449
+ this.logger.debug?.(
1450
+ `[${this.label}] paste not submitted (no user-message for ${token}), `
1451
+ + `re-sending Enter (attempt ${attempt + 1})`,
1452
+ );
1453
+ try {
1454
+ await this.runner.sendControl(this.tmuxName, 'Enter');
1455
+ } catch (err) {
1456
+ // A dead session — the turn will fail loud via another racer.
1457
+ this.logger.debug?.(`[${this.label}] retry Enter failed: ${err.message}`);
1458
+ return;
1459
+ }
1460
+ }
1461
+ throw Object.assign(
1462
+ new Error(
1463
+ `TmuxProcess: prompt never submitted — no JSONL user-message for `
1464
+ + `${token} after ${this.submitConfirmRetries + 1} Enter attempts`,
1465
+ ),
1466
+ { code: 'TMUX_SUBMIT_FAILED', tmuxName: this.tmuxName, token },
1467
+ );
1468
+ }
1469
+
1470
+ /**
1471
+ * Resolve `true` once `token` surfaces in a JSONL `user-message`
1472
+ * (via `_confirmSubmit`), or `false` after `submitConfirmMs`.
1473
+ * Distinct from `_awaitPasteConfirm`: that releases the next-paste
1474
+ * barrier on user-message OR queue-operation; this confirms a turn
1475
+ * actually STARTED, so it accepts the `user-message` signal only.
1476
+ */
1477
+ _awaitSubmitConfirm(token) {
1478
+ return new Promise((resolve) => {
1479
+ let done = false;
1480
+ let timer = null;
1481
+ const finish = (ok) => {
1482
+ if (done) return;
1483
+ done = true;
1484
+ if (timer) clearTimeout(timer);
1485
+ this._submitConfirms.delete(token);
1486
+ resolve(ok);
1487
+ };
1488
+ this._submitConfirms.set(token, () => finish(true));
1489
+ timer = setTimeout(() => finish(false), this.submitConfirmMs);
1490
+ timer.unref?.();
1491
+ });
1492
+ }
1493
+
1494
+ /** B7: mark tokens as submit-confirmed — fired from a JSONL
1495
+ * `user-message` (NOT queue-operation). */
1496
+ _confirmSubmit(tokens) {
1497
+ for (const t of tokens) {
1498
+ const finish = this._submitConfirms.get(t);
1499
+ if (finish) finish();
1500
+ }
1501
+ }
1502
+
1324
1503
  /**
1325
1504
  * Resolve once `token` surfaces in a JSONL user-message /
1326
1505
  * queue-operation, or after `pasteConfirmMs` (bounded barrier).
@@ -1363,6 +1542,40 @@ class TmuxProcess extends Process {
1363
1542
  async _waitForReady() {
1364
1543
  const deadline = this._now() + this.readyTimeoutMs;
1365
1544
  let lastBuf = '';
1545
+ // B6 (shumorobot 2026-05-18, Music topic, twice): a slow
1546
+ // custom-agent spawn (`music-curation:music-curator` loading
1547
+ // several MCP servers) leaves the claude TUI mid-startup for
1548
+ // SECONDS — the production debug log shows MCP connections
1549
+ // spanning 14:45:31→14:45:40 (playwright 2.9s, context7 3.1s,
1550
+ // serena 3.8s, …) with the screen repainting hard the whole time
1551
+ // ("High write ratio: 100.0% writes"). Throughout that window the
1552
+ // TUI ALREADY renders its ready hint (`? for shortcuts` /
1553
+ // `bypass permissions on`) at the bottom of its startup banner.
1554
+ //
1555
+ // The old `_waitForReady` returned the INSTANT `READY_HINTS_RE`
1556
+ // matched — i.e. on the first poll, while MCP servers were still
1557
+ // loading. `start()` resolved early; the first `send()` pasted
1558
+ // the prompt into a TUI still ingesting startup, and the
1559
+ // submitted Enter was dropped → the prompt sat unsubmitted → the
1560
+ // turn never began (a fake THINKING→STALL followed). On a
1561
+ // no-agent TUI the startup window is sub-poll so it never bit; a
1562
+ // slow custom-agent spawn opens a multi-second window every time.
1563
+ //
1564
+ // The banner is NOT a usable "not ready" signal — it stays on the
1565
+ // pane indefinitely (the agent emits nothing pre-turn, so it
1566
+ // never scrolls into scrollback). The real discriminator is
1567
+ // QUIESCENCE: a genuinely-ready idle TUI produces a BYTE-STABLE
1568
+ // `capture-pane` between polls (static banner + empty input box +
1569
+ // ready hint), whereas a mid-startup TUI repaints every tick.
1570
+ //
1571
+ // Fix: require the ready hint to be present AND the captured pane
1572
+ // to be UNCHANGED across consecutive polls for `quiesceMs`
1573
+ // continuously before declaring ready — the same "must hold this
1574
+ // long" idea `_awaitTurnComplete` already uses for turn
1575
+ // completion. Bounded by `readyTimeoutMs`, so a genuinely wedged
1576
+ // spawn still throws TMUX_READY_TIMEOUT.
1577
+ let readySinceAt = null; // when the (hint + stable-pane) state began
1578
+ let prevBuf = null; // last poll's capture, for the stability compare
1366
1579
  if (this.pollScheduler) this.pollScheduler.acquire();
1367
1580
  try {
1368
1581
  while (this._now() < deadline) {
@@ -1370,7 +1583,20 @@ class TmuxProcess extends Process {
1370
1583
  // pane. Polling 1000 lines each tick is wasteful — cap at 80
1371
1584
  // for a ~12× cheaper tmux subprocess.
1372
1585
  lastBuf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
1373
- if (READY_HINTS_RE.test(lastBuf)) return;
1586
+ // Ready ⇔ the hint is on the pane AND the pane is identical to
1587
+ // the previous poll (the MCP-loading repaint storm has
1588
+ // stopped). The first poll has no previous buffer to compare,
1589
+ // so it can never satisfy stability — readiness needs at
1590
+ // least two matching captures, which is the point.
1591
+ const hintPresent = READY_HINTS_RE.test(lastBuf);
1592
+ const paneStable = prevBuf !== null && lastBuf === prevBuf;
1593
+ if (hintPresent && paneStable) {
1594
+ if (readySinceAt == null) readySinceAt = this._now();
1595
+ if (this._now() - readySinceAt >= this.quiesceMs) return;
1596
+ } else {
1597
+ readySinceAt = null; // pane moved / hint gone → reset the clock
1598
+ }
1599
+ prevBuf = lastBuf;
1374
1600
  await this._waitForNextTick();
1375
1601
  }
1376
1602
  } finally {
@@ -1880,6 +2106,18 @@ class TmuxProcess extends Process {
1880
2106
  for (const finish of [...this._pasteConfirms.values()]) {
1881
2107
  try { finish(); } catch { /* swallow */ }
1882
2108
  }
2109
+ // B7: release any pending submit-confirm waiters too — a
2110
+ // `_confirmSubmitViaJsonl` blocked on a tokened user-message from a
2111
+ // now-dead session would otherwise burn its whole retry budget.
2112
+ // Each waiter's stored fn resolves it as confirmed, so the confirm
2113
+ // loop returns at once instead of retrying; the in-flight turn is
2114
+ // already rejected by `drainQueue` above, so the turn settles loud
2115
+ // regardless. (`_confirmSubmitViaJsonl` also bails on its own when
2116
+ // the owning turn reaches a terminal state — this is belt-and-
2117
+ // braces for a confirm whose turn ref it never received.)
2118
+ for (const finish of [...this._submitConfirms.values()]) {
2119
+ try { finish(); } catch { /* swallow */ }
2120
+ }
1883
2121
  if (this._sessionLogTail) {
1884
2122
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
1885
2123
  this._sessionLogTail = null;
@@ -46,78 +46,65 @@ 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 };
49
+ // 2026-05-18 incident submit confirmation now lives in TmuxProcess.
50
+ // A ~1-2KB polygram prompt is collapsed by the claude TUI into a
51
+ // "[Pasted text #N]" placeholder; the single post-paste Enter can be
52
+ // absorbed mid-ingest, leaving the prompt unsubmitted.
53
+ //
54
+ // B5 confirmed the submit HERE by capture-pane (does the input box
55
+ // still hold the paste?). That FALSE-POSITIVED: the TUI renders the
56
+ // collapsed-paste placeholder asynchronously, so a capture-pane poll
57
+ // catches a transient frame where the placeholder is not yet visible
58
+ // and B5 wrongly concluded "submitted ✓", leaving the prompt stuck
59
+ // (3rd recurrence: shumorobot msg 803, 2026-05-19). B7 REMOVED the
60
+ // capture-pane confirm capture-pane is an unreliable signal for a
61
+ // collapsed paste. Submission is now confirmed in `TmuxProcess` by the
62
+ // paste's correlation token surfacing in a JSONL `user-message` (the
63
+ // only reliable "the prompt reached claude" signal). The runner just
64
+ // pastes + Enter; it no longer judges whether the submit landed.
104
65
 
105
66
  // ─── execFile wrapper ────────────────────────────────────────────────
106
67
 
68
+ // Every tmux invocation polygram makes — capture-pane, send-keys,
69
+ // set-buffer, paste-buffer, has-session, list-sessions, kill-session,
70
+ // and even `new-session -d` (which detaches immediately) — is a
71
+ // sub-second operation. A tmux call that runs longer than this is
72
+ // WEDGED (tmux server hung, host pathologically loaded).
73
+ //
74
+ // Without a bound, a wedged subprocess hangs the `await` forever:
75
+ // `_awaitTurnComplete`'s poll loop re-checks its deadline only
76
+ // BETWEEN `captureWide` calls, so a single hung `capture-pane` stalls
77
+ // the turn with no timeout (leftover R7). The 2026-05-18 submit-
78
+ // confirm loop has the same exposure — it capture-panes too.
79
+ //
80
+ // A per-exec timeout bounds ALL of it: a timed-out tmux call rejects
81
+ // → the caller throws → the turn fails LOUD with an error instead of
82
+ // hanging. killSignal is SIGKILL because a wedged process may ignore
83
+ // SIGTERM. 10s is generous headroom over the sub-second norm.
84
+ const TMUX_RUN_TIMEOUT_MS = 10_000;
85
+
107
86
  /**
108
87
  * Promise-wrapped childProcess.execFile. Returns { stdout, stderr }.
109
- * Rejects on non-zero exit with err.stdout + err.stderr attached.
88
+ * Rejects on non-zero exit (or timeout) with err.stdout + err.stderr
89
+ * attached. A default timeout + SIGKILL bound every tmux call so a
90
+ * wedged subprocess cannot hang a turn (leftover R7); an explicit
91
+ * `opts.timeout` still overrides.
110
92
  */
111
93
  function run(cmd, args, opts = {}) {
112
94
  return new Promise((resolve, reject) => {
113
- childProcess.execFile(cmd, args, { ...opts, encoding: 'utf8' }, (err, stdout, stderr) => {
114
- if (err) {
115
- err.stdout = stdout;
116
- err.stderr = stderr;
117
- return reject(err);
118
- }
119
- resolve({ stdout, stderr });
120
- });
95
+ childProcess.execFile(
96
+ cmd,
97
+ args,
98
+ { timeout: TMUX_RUN_TIMEOUT_MS, killSignal: 'SIGKILL', ...opts, encoding: 'utf8' },
99
+ (err, stdout, stderr) => {
100
+ if (err) {
101
+ err.stdout = stdout;
102
+ err.stderr = stderr;
103
+ return reject(err);
104
+ }
105
+ resolve({ stdout, stderr });
106
+ },
107
+ );
121
108
  });
122
109
  }
123
110
 
@@ -195,16 +182,11 @@ function ensureLogDir(logPath) {
195
182
  * @param {object} [opts.logger=console]
196
183
  * @param {Function} [opts.runFn] — override the underlying execFile
197
184
  * 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.
200
185
  */
201
186
  function createTmuxRunner({
202
187
  logger = console,
203
188
  runFn = run,
204
- submitConfirm = {},
205
189
  } = {}) {
206
- const submitCfg = { ...DEFAULT_SUBMIT_CONFIRM, ...submitConfirm };
207
-
208
190
  async function spawn({
209
191
  name,
210
192
  cwd,
@@ -278,21 +260,17 @@ function createTmuxRunner({
278
260
  /**
279
261
  * Paste a prompt body + press Enter, atomically per session.
280
262
  *
263
+ * Pure mechanics: paste, Enter, a small post-Enter drain. The runner
264
+ * does NOT judge whether the submit landed — B7 moved submit
265
+ * confirmation to `TmuxProcess`, which confirms it via the paste's
266
+ * correlation token surfacing in a JSONL `user-message` (the only
267
+ * reliable signal; capture-pane false-positives on a collapsed
268
+ * `[Pasted text #N]` placeholder).
269
+ *
281
270
  * @param {string} name tmux session
282
271
  * @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
272
  */
295
- async function pasteAndEnter(name, text, { confirmSubmit = false } = {}) {
273
+ async function pasteAndEnter(name, text) {
296
274
  const release = await inputLock.acquire(name);
297
275
  try {
298
276
  const res = await pasteText(name, text);
@@ -306,46 +284,7 @@ function createTmuxRunner({
306
284
  // Enter's processing. 50ms is enough on the TUI we tested
307
285
  // against (claude v2.1.142); see AGENTS.md pinned version.
308
286
  await new Promise((r) => setTimeout(r, 50));
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
- );
287
+ return res;
349
288
  } finally {
350
289
  release();
351
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.23",
3
+ "version": "0.10.0-rc.25",
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": {