polygram 0.10.0-rc.24 → 0.10.0-rc.26

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.24",
4
+ "version": "0.10.0-rc.26",
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",
@@ -98,7 +98,8 @@
98
98
  "cwd": "/Users/you/admin-agent",
99
99
  "requireMention": true,
100
100
  "isolateTopics": true,
101
- "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
101
+ "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode, isolateUserConfig}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
102
+ "_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
102
103
  "topics": {
103
104
  "100": "Customer A",
104
105
  "200": {
@@ -107,7 +108,8 @@
107
108
  "cwd": "/Users/you/customer-b-projects",
108
109
  "model": "opus",
109
110
  "effort": "high",
110
- "permissionMode": "default"
111
+ "permissionMode": "default",
112
+ "isolateUserConfig": true
111
113
  }
112
114
  }
113
115
  },
@@ -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; }
@@ -323,7 +347,7 @@ class TmuxProcess extends Process {
323
347
  *
324
348
  * @param {object} ctx
325
349
  * @param {string|null} [ctx.existingSessionId] — for --resume
326
- * @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode
350
+ * @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode, isolateUserConfig
327
351
  * @param {string} [ctx.model] — override (rare; e.g. tests)
328
352
  * @param {string} [ctx.effort] — override
329
353
  * @param {string} [ctx.cwd] — override
@@ -354,6 +378,21 @@ class TmuxProcess extends Process {
354
378
  const cwd = ctx.cwd || topicConfig.cwd || chatConfig.cwd;
355
379
  const agent = topicConfig.agent || chatConfig.agent;
356
380
  const permissionMode = topicConfig.permissionMode || chatConfig.permissionMode || 'acceptEdits';
381
+ // `isolateUserConfig` (topic- or chat-level, topic wins — same
382
+ // merge path as agent/cwd/permissionMode). When true, the spawned
383
+ // claude TUI is cut off from the user-level `~/.claude` config:
384
+ // no user-level MCP servers, plugins, or settings load. Decided
385
+ // fix for the Music topic incident — the music-curation agent was
386
+ // pulling in user-global MCP servers (serena ~27.5 s, peekaboo
387
+ // ~9 s, context7) and the ~45 s MCP cold-start left the TUI
388
+ // accepting a pasted prompt but dropping the submitted Enter, so
389
+ // polygram's paste never submitted and the turn failed (broke the
390
+ // Music topic 5+ times). Default OFF — every other topic is
391
+ // unaffected unless it explicitly opts in.
392
+ const isolateUserConfig =
393
+ topicConfig.isolateUserConfig != null
394
+ ? topicConfig.isolateUserConfig === true
395
+ : chatConfig.isolateUserConfig === true;
357
396
 
358
397
  // Pre-allocate the sessionId via --session-id flag (v9 finding).
359
398
  // claude accepts a valid UUID and uses it as THE session ID for the
@@ -376,6 +415,25 @@ class TmuxProcess extends Process {
376
415
  }
377
416
  args.push('--debug-file', this.debugLogPath);
378
417
  if (agent) args.push('--agent', agent);
418
+ // isolateUserConfig: cut the spawned TUI off from `~/.claude`.
419
+ // --strict-mcp-config — claude CLI v2.1.142: "Only use MCP
420
+ // servers from --mcp-config, ignoring all other MCP
421
+ // configurations." Passed ALONE (no --mcp-config) → zero MCP
422
+ // servers load. Hard guarantee that no plugin-provided OR
423
+ // directly-registered MCP server (serena/peekaboo/context7)
424
+ // starts, so there is no ~45 s cold-start window.
425
+ // --setting-sources project,local — load only project + local
426
+ // settings, NOT `user`. Drops `~/.claude/settings.json`
427
+ // (user-level plugins/skills/settings) while the spawn cwd's
428
+ // own `.claude/settings.json` still loads — so the rekordbox
429
+ // project's WebFetch allowlist + dontAsk mode still apply.
430
+ // No --mcp-config needed: the music-curation plugin ships NO MCP
431
+ // server (its .claude-plugin/plugin.json declares no `mcpServers`;
432
+ // it is all-Bash), so --strict-mcp-config alone is clean.
433
+ if (isolateUserConfig) {
434
+ args.push('--strict-mcp-config');
435
+ args.push('--setting-sources', 'project,local');
436
+ }
379
437
  // Cross-backend parity: SDK appends polygram's Telegram display
380
438
  // hint to every agent's systemPrompt (lib/sdk/build-options.js).
381
439
  // Without this, the spawned claude session has no idea it's
@@ -563,15 +621,8 @@ class TmuxProcess extends Process {
563
621
  // rc.13.1: pasteAndEnter holds a per-session async lock around
564
622
  // paste + Enter so a concurrent injectUserMessage paste cannot
565
623
  // 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
624
  const result = await this._pasteAndEnter(
574
- this._embedToken(turn.prompt, turn.token), { confirmSubmit: true });
625
+ this._embedToken(turn.prompt, turn.token));
575
626
  if (result.stripped > 0) {
576
627
  this.logger.warn?.(
577
628
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
@@ -579,6 +630,34 @@ class TmuxProcess extends Process {
579
630
  this.emit('prompt-sanitized', { stripped: result.stripped, source: 'send' });
580
631
  }
581
632
 
633
+ // B7 (shumorobot msgs 789/791/803): a PRIMARY turn pasting into
634
+ // an idle TUI MUST start a turn. A production polygram prompt is
635
+ // ~1-2KB → the claude TUI collapses it into a `[Pasted text #N]`
636
+ // placeholder whose single post-paste Enter can be absorbed
637
+ // mid-ingest, leaving the prompt UNSUBMITTED — the turn never
638
+ // starts. `_confirmSubmitViaJsonl` confirms the submit landed by
639
+ // waiting for this paste's correlation token to surface in a
640
+ // JSONL `user-message` (the ONLY reliable signal — capture-pane
641
+ // false-positives on the collapsed placeholder); it re-sends
642
+ // Enter on a miss and, after bounded retries, REJECTS with
643
+ // TMUX_SUBMIT_FAILED.
644
+ //
645
+ // The confirm drives TWO derived promises below:
646
+ // submitConfirmP — rejects TMUX_SUBMIT_FAILED → a racer that
647
+ // fails the turn fast and loud; on success it never settles.
648
+ // submitOkP — resolves ONLY once the submit is confirmed;
649
+ // on failure it never settles. It GATES the capture-pane
650
+ // racer: an "idle" capture-pane is meaningless until the turn
651
+ // has actually started — pre-gate, a `[Pasted text #N]`
652
+ // prompt sitting unsubmitted reads as an idle (=="complete")
653
+ // pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
654
+ // and mask the real TMUX_SUBMIT_FAILED cause.
655
+ const confirmP = turn.token
656
+ ? this._confirmSubmitViaJsonl(turn.token, turn)
657
+ : Promise.resolve(); // no token — nothing to confirm
658
+ const submitConfirmP = confirmP.then(() => new Promise(() => {}));
659
+ const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
660
+
582
661
  // R7: an ABSOLUTE timeout wrapping the whole race. The
583
662
  // capture-pane completion detector re-checks its deadline only
584
663
  // BETWEEN `captureWide` subprocess calls — if a single `tmux
@@ -617,11 +696,32 @@ class TmuxProcess extends Process {
617
696
  timeoutMs: turnTimeoutMs, abortP,
618
697
  });
619
698
  // 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
- ));
699
+ // (it returns the abort sentinel) or reject (its own timeout).
700
+ // Both are swallowed here the `turnDeadlineP` reject below is
701
+ // what actually fails the turn.
702
+ //
703
+ // B7: gate the capture racer behind `submitOkP`. A capture-pane
704
+ // "idle" reading only means "the turn completed" once the turn
705
+ // has actually STARTED. If the paste never submitted, the pane is
706
+ // idle because the prompt is still sitting in the input box — not
707
+ // because a turn finished. Without the gate the capture racer
708
+ // would win with TMUX_NO_JSONL_TEXT and mask the real
709
+ // TMUX_SUBMIT_FAILED. `captureCompleteP` is still awaited inside
710
+ // the racer (so its rejection is always handled — never orphaned
711
+ // into an unhandled rejection) and the poll loop / scheduler
712
+ // refcount lifecycle is unchanged; only the racer's eligibility
713
+ // to WIN is gated on submit confirmation.
714
+ const captureRaceP = (async () => {
715
+ let buf;
716
+ try {
717
+ buf = await captureCompleteP;
718
+ } catch {
719
+ return new Promise(() => {}); // capture's own timeout — turnDeadlineP fails the turn
720
+ }
721
+ if (buf === ABORT_SENTINEL) return new Promise(() => {});
722
+ await submitOkP; // gate: capture cannot win pre-submit-confirm
723
+ return { kind: 'capture' };
724
+ })();
625
725
 
626
726
  let resolvedVia = 'jsonl';
627
727
  let winner;
@@ -631,6 +731,8 @@ class TmuxProcess extends Process {
631
731
  captureRaceP,
632
732
  turnDeadlineP,
633
733
  turn.interruptP.then(() => ({ kind: 'interrupt' })),
734
+ // B7: TMUX_SUBMIT_FAILED rejection fails the turn fast.
735
+ submitConfirmP,
634
736
  ]);
635
737
 
636
738
  // If capture-pane won but the turn used a tool, the agent is
@@ -1151,6 +1253,10 @@ class TmuxProcess extends Process {
1151
1253
  // Phase 3 §5: a user-message proves its pastes landed — release
1152
1254
  // the paste gate for those tokens.
1153
1255
  this._confirmPaste(tokens);
1256
+ // B7: a user-message is the proof that a primary paste actually
1257
+ // STARTED a turn (claude registered the prompt). Release any
1258
+ // _confirmSubmitViaJsonl waiter for these tokens.
1259
+ this._confirmSubmit(tokens);
1154
1260
  let matched = [];
1155
1261
  for (const tok of tokens) {
1156
1262
  const t = this._ledger.find(
@@ -1166,7 +1272,15 @@ class TmuxProcess extends Process {
1166
1272
  const orphan = this._ledger.find(
1167
1273
  (x) => x.kind === 'primary' && x.state === 'pasted',
1168
1274
  );
1169
- if (orphan) matched = [orphan];
1275
+ if (orphan) {
1276
+ matched = [orphan];
1277
+ // B7: a token-less user-message claimed by the orphan
1278
+ // fallback still PROVES that primary's prompt started — so
1279
+ // release its submit-confirm waiter under the orphan's own
1280
+ // token (the `_confirmSubmit(tokens)` above could not, the
1281
+ // user-message carried no token to match).
1282
+ this._confirmSubmit([orphan.token]);
1283
+ }
1170
1284
  }
1171
1285
  if (matched.length === 0) {
1172
1286
  // Genuinely unrecognised — diagnostic, then ignore. (A
@@ -1290,19 +1404,33 @@ class TmuxProcess extends Process {
1290
1404
  * pastes can no longer concatenate. The lock is released
1291
1405
  * asynchronously (on confirm/timeout) so it gates only the NEXT
1292
1406
  * paste, never delays this caller.
1407
+ *
1408
+ * B7 (shumorobot msgs 789/791/803): `_pasteAndEnter` does NOT itself
1409
+ * confirm the paste SUBMITTED. A large prompt collapses in the claude
1410
+ * TUI into a `[Pasted text #N]` placeholder whose single post-paste
1411
+ * Enter can be absorbed mid-ingest, leaving the prompt unsubmitted —
1412
+ * but that submit-confirmation runs as a concurrent racer in
1413
+ * `_runTurn` (`_confirmSubmitViaJsonl`), NOT here. Blocking
1414
+ * `_pasteAndEnter` on the confirm would hold `_pasteLock` across the
1415
+ * whole confirm window and stall every following paste — an autosteer
1416
+ * that should fold into the primary turn could never paste.
1293
1417
  */
1294
- async _pasteAndEnter(text, { confirmSubmit = false } = {}) {
1418
+ async _pasteAndEnter(text) {
1295
1419
  const token = this._extractTokens(text)[0] || null;
1296
1420
  const release = await this._pasteLock.acquire(this.tmuxName);
1297
1421
  let result;
1298
1422
  try {
1423
+ // B7: the runner no longer does capture-pane submit-confirmation
1424
+ // (it false-positived on `[Pasted text #N]`). The runner just
1425
+ // pastes + Enter. Submit confirmation for a PRIMARY turn is
1426
+ // JSONL-token-based and runs as a CONCURRENT racer in `_runTurn`
1427
+ // (`_confirmSubmitViaJsonl`) — NOT here. Blocking `_pasteAndEnter`
1428
+ // on the confirm would hold `_pasteLock` across the whole confirm
1429
+ // window and stall every following paste (an autosteer that
1430
+ // SHOULD fold into the primary turn could never paste). The
1431
+ // confirm is a watchdog, not a paste-pipeline gate.
1299
1432
  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 });
1433
+ result = await this.runner.pasteAndEnter(this.tmuxName, text);
1306
1434
  } else {
1307
1435
  result = await this.runner.pasteText(this.tmuxName, text);
1308
1436
  await this.runner.sendControl(this.tmuxName, 'Enter');
@@ -1321,6 +1449,91 @@ class TmuxProcess extends Process {
1321
1449
  return result;
1322
1450
  }
1323
1451
 
1452
+ /**
1453
+ * B7: confirm a primary paste actually submitted by waiting for its
1454
+ * correlation `token` to surface in a JSONL `user-message`. On each
1455
+ * miss, re-send Enter (the prior Enter was absorbed by the TUI's
1456
+ * bracketed-paste ingest of a `[Pasted text #N]` block). After
1457
+ * `submitConfirmRetries` exhausted misses, throw `TMUX_SUBMIT_FAILED`.
1458
+ *
1459
+ * The signal is the `user-message` ONLY — not `queue-operation`. A
1460
+ * `queue-operation enqueue` means the paste was parked in the TUI
1461
+ * queue, which for a primary paste into an idle TUI would itself be
1462
+ * wrong; the genuine "the prompt started a turn" proof is the
1463
+ * `user-message` claude writes when it registers the prompt.
1464
+ *
1465
+ * Runs as a concurrent racer in `_runTurn` (NOT a blocking gate in
1466
+ * `_pasteAndEnter` — that would hold `_pasteLock` across the confirm
1467
+ * window and stall a folding autosteer's paste). `turn` is the owning
1468
+ * Turn: if it reaches a terminal state (the real result/capture
1469
+ * racer already won, or the turn was killed) the retry loop bails so
1470
+ * a stray retry Enter cannot land in an unrelated turn.
1471
+ */
1472
+ async _confirmSubmitViaJsonl(token, turn = null) {
1473
+ for (let attempt = 0; attempt <= this.submitConfirmRetries; attempt += 1) {
1474
+ const confirmed = await this._awaitSubmitConfirm(token);
1475
+ if (confirmed) return; // submitted ✓
1476
+ // The turn already settled some other way (result/capture/kill)
1477
+ // — the submit clearly is no longer the open question. Stop:
1478
+ // re-sending Enter or throwing now would be wrong.
1479
+ if (turn && (turn.state === 'done' || turn.state === 'failed')) return;
1480
+ if (attempt === this.submitConfirmRetries) break; // out of retries
1481
+ // The tokened user-message never arrived — the prompt is still
1482
+ // sitting in the input box as `[Pasted text #N]`. Re-send Enter.
1483
+ this.logger.debug?.(
1484
+ `[${this.label}] paste not submitted (no user-message for ${token}), `
1485
+ + `re-sending Enter (attempt ${attempt + 1})`,
1486
+ );
1487
+ try {
1488
+ await this.runner.sendControl(this.tmuxName, 'Enter');
1489
+ } catch (err) {
1490
+ // A dead session — the turn will fail loud via another racer.
1491
+ this.logger.debug?.(`[${this.label}] retry Enter failed: ${err.message}`);
1492
+ return;
1493
+ }
1494
+ }
1495
+ throw Object.assign(
1496
+ new Error(
1497
+ `TmuxProcess: prompt never submitted — no JSONL user-message for `
1498
+ + `${token} after ${this.submitConfirmRetries + 1} Enter attempts`,
1499
+ ),
1500
+ { code: 'TMUX_SUBMIT_FAILED', tmuxName: this.tmuxName, token },
1501
+ );
1502
+ }
1503
+
1504
+ /**
1505
+ * Resolve `true` once `token` surfaces in a JSONL `user-message`
1506
+ * (via `_confirmSubmit`), or `false` after `submitConfirmMs`.
1507
+ * Distinct from `_awaitPasteConfirm`: that releases the next-paste
1508
+ * barrier on user-message OR queue-operation; this confirms a turn
1509
+ * actually STARTED, so it accepts the `user-message` signal only.
1510
+ */
1511
+ _awaitSubmitConfirm(token) {
1512
+ return new Promise((resolve) => {
1513
+ let done = false;
1514
+ let timer = null;
1515
+ const finish = (ok) => {
1516
+ if (done) return;
1517
+ done = true;
1518
+ if (timer) clearTimeout(timer);
1519
+ this._submitConfirms.delete(token);
1520
+ resolve(ok);
1521
+ };
1522
+ this._submitConfirms.set(token, () => finish(true));
1523
+ timer = setTimeout(() => finish(false), this.submitConfirmMs);
1524
+ timer.unref?.();
1525
+ });
1526
+ }
1527
+
1528
+ /** B7: mark tokens as submit-confirmed — fired from a JSONL
1529
+ * `user-message` (NOT queue-operation). */
1530
+ _confirmSubmit(tokens) {
1531
+ for (const t of tokens) {
1532
+ const finish = this._submitConfirms.get(t);
1533
+ if (finish) finish();
1534
+ }
1535
+ }
1536
+
1324
1537
  /**
1325
1538
  * Resolve once `token` surfaces in a JSONL user-message /
1326
1539
  * queue-operation, or after `pasteConfirmMs` (bounded barrier).
@@ -1927,6 +2140,18 @@ class TmuxProcess extends Process {
1927
2140
  for (const finish of [...this._pasteConfirms.values()]) {
1928
2141
  try { finish(); } catch { /* swallow */ }
1929
2142
  }
2143
+ // B7: release any pending submit-confirm waiters too — a
2144
+ // `_confirmSubmitViaJsonl` blocked on a tokened user-message from a
2145
+ // now-dead session would otherwise burn its whole retry budget.
2146
+ // Each waiter's stored fn resolves it as confirmed, so the confirm
2147
+ // loop returns at once instead of retrying; the in-flight turn is
2148
+ // already rejected by `drainQueue` above, so the turn settles loud
2149
+ // regardless. (`_confirmSubmitViaJsonl` also bails on its own when
2150
+ // the owning turn reaches a terminal state — this is belt-and-
2151
+ // braces for a confirm whose turn ref it never received.)
2152
+ for (const finish of [...this._submitConfirms.values()]) {
2153
+ try { finish(); } catch { /* swallow */ }
2154
+ }
1930
2155
  if (this._sessionLogTail) {
1931
2156
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
1932
2157
  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.24",
3
+ "version": "0.10.0-rc.26",
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": {