polygram 0.10.0-rc.13 → 0.10.0-rc.15

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.13",
4
+ "version": "0.10.0-rc.15",
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",
@@ -54,7 +54,16 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
54
54
  // for the next prompt. Under `--permission-mode acceptEdits` (our
55
55
  // default), the bottom-of-pane indicator can also read "accept edits
56
56
  // on" instead; treat either as ready.
57
- const READY_HINTS_RE = /\?\s+for shortcuts|accept edits on/;
57
+ // claude TUI shows a different ready hint depending on permission
58
+ // mode:
59
+ // - default: "? for shortcuts"
60
+ // - acceptEdits: "accept edits on"
61
+ // - bypassPermissions: "bypass permissions on (shift+tab to cycle)"
62
+ // All three are valid ready states. Polygram production uses
63
+ // 'default', but the spike harness + tests exercising
64
+ // bypassPermissions need the third matcher (caught by the rc.14
65
+ // autosteer-tui-real.mjs spike).
66
+ const READY_HINTS_RE = /\?\s+for shortcuts|accept edits on|bypass permissions on/;
58
67
  const STREAMING_HINT_RE = /esc to interrupt/;
59
68
 
60
69
  // TUI approval-prompt indicators. When a chat is spawned WITHOUT
@@ -387,14 +396,18 @@ class TmuxProcess extends Process {
387
396
  try {
388
397
  // R2-F1: sanitization happens inside runner.pasteText; we also
389
398
  // log when chars get stripped.
390
- const result = await this.runner.pasteText(this.tmuxName, prompt);
399
+ // rc.13.1: pasteAndEnter holds a per-session async lock around
400
+ // paste + Enter so a concurrent injectUserMessage paste can't
401
+ // interleave keystrokes with this primary turn's prompt
402
+ // (shumorobot 2026-05-15 saw msg 696's prompt truncated at
403
+ // `chat_id="-1003` by msg 698's autosteer paste cutting in).
404
+ const result = await this._pasteAndEnter(prompt);
391
405
  if (result.stripped > 0) {
392
406
  this.logger.warn?.(
393
407
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
394
408
  );
395
409
  this.emit('prompt-sanitized', { stripped: result.stripped, source: 'send' });
396
410
  }
397
- await this.runner.sendControl(this.tmuxName, 'Enter');
398
411
 
399
412
  // Race: JSONL result event vs capture-pane quiescence fallback
400
413
  // vs hard timeout. JSONL is the primary signal (carries structured
@@ -692,14 +705,35 @@ class TmuxProcess extends Process {
692
705
  // its terminal result fires; only AFTER _extraTurnState is
693
706
  // flushed do subsequent events go to the next primary turn.
694
707
  if (this._extraTurnState) {
708
+ // rc.14.3: don't flush an empty extra-turn-state. The
709
+ // agent may emit a `thinking`-only assistant message
710
+ // (kinds=['thinking']) with stop_reason=end_turn BEFORE
711
+ // emitting the real text reply. parseLine fires a result
712
+ // event for the thinking message (empty text) — if we
713
+ // flush here we'd emit empty extra-turn-reply events and
714
+ // the subsequent real-text message would have no state to
715
+ // accumulate into (would leak to autonomous-assistant-
716
+ // message instead). Wait for a result that has actual
717
+ // text accumulated. Spike multi-2-rapid hit this.
718
+ if (!this._extraTurnState.text) {
719
+ return;
720
+ }
695
721
  const flushed = this._extraTurnState;
696
722
  this._extraTurnState = null;
697
- this.emit('extra-turn-reply', {
698
- msgId: flushed.msgId,
699
- text: flushed.text,
700
- sessionId: this.claudeSessionId,
701
- backend: 'tmux',
702
- });
723
+ // rc.14.1: _extraTurnState carries an array of msgIds (one
724
+ // dequeued user-message may resolve N pending autosteers
725
+ // when the TUI's queue concatenated multiple pastes). Emit
726
+ // one extra-turn-reply per msgId with the shared text so
727
+ // polygram delivers a Telegram reply per autosteered msg.
728
+ const msgIds = Array.isArray(flushed.msgIds) ? flushed.msgIds : [flushed.msgId];
729
+ for (const msgId of msgIds) {
730
+ this.emit('extra-turn-reply', {
731
+ msgId,
732
+ text: flushed.text,
733
+ sessionId: this.claudeSessionId,
734
+ backend: 'tmux',
735
+ });
736
+ }
703
737
  } else if (this._turnState && this._turnState.resolveResult) {
704
738
  this._turnState.resultEvent = ev;
705
739
  this._turnState.resolveResult(ev);
@@ -749,49 +783,74 @@ class TmuxProcess extends Process {
749
783
  // runs AFTER the first turn returned), start an extra-turn
750
784
  // accumulator. Subsequent assistant-chunks + the next 'result'
751
785
  // event will flush it as 'extra-turn-reply'.
752
- const idx = this._pendingAutosteers.findIndex((a) => a.content === ev.text);
753
- // rc.12.1: user-message handler can run while _turnState is
754
- // still set (the prior turn's send() hasn't called
755
- // _completeTurn yet it's behind a pending microtask after
756
- // its result resolved). Don't gate on _turnState being null
757
- // here; the assistant-chunk + result routing above already
758
- // ensures turn 2's events flow into _extraTurnState even when
759
- // _turnState (for turn 3) exists. The only thing we need
760
- // exclusive of is another in-progress _extraTurnState.
761
- if (idx >= 0 && !this._extraTurnState) {
762
- const { msgId } = this._pendingAutosteers[idx];
763
- this._pendingAutosteers.splice(idx, 1);
764
- this._extraTurnState = { msgId, text: '' };
765
- // rc.9: signal that turn 2 just started for the autosteered
766
- // msg. Polygram subscribes to re-engage typing indicator + ✍
767
- // reaction on msgId during the gap (clearAutosteeredReactions
768
- // had fired when primary turn 1 succeeded under SDK-style
769
- // fold assumption — for tmux NEW-TURN that clear was
770
- // premature; this event lets polygram restore the visual).
771
- this.emit('extra-turn-started', {
772
- msgId,
773
- sessionId: this.claudeSessionId,
774
- backend: 'tmux',
775
- });
776
- // rc.11.1: also emit observability event so post-hoc
777
- // diagnosis can confirm which path each autosteer took.
778
- this.emit('autosteer-resolution', {
779
- msgId,
780
- via: 'new-turn',
781
- sessionId: this.claudeSessionId,
782
- backend: 'tmux',
783
- });
786
+ // rc.14.1: SUBSTRING match. When two injectUserMessage calls
787
+ // land in the same tick (back-to-back autosteer), the TUI's
788
+ // queue concatenates their pastes into ONE queue entry
789
+ // separated by `\r` (Enter bytes), then dequeues as a SINGLE
790
+ // top-level user message containing BOTH contents. Caught by
791
+ // the 50-scenario spike's multi-2-rapid:
792
+ // QUEUE-ENQUEUE: 'Inject 1...\rInject 2...'
793
+ // USER: 'Inject 1...\rInject 2...'
794
+ // Pre-rc.14.1 strict equality matched neither pending → both
795
+ // orphaned. Substring match pops ALL pending whose content
796
+ // appears in ev.text. The result handler emits one
797
+ // extra-turn-reply per msgId (best UX given they got
798
+ // submitted as one TUI input — agent's reply addresses
799
+ // all of them).
800
+ const matchedIdxs = [];
801
+ for (let i = 0; i < this._pendingAutosteers.length; i++) {
802
+ if (ev.text.includes(this._pendingAutosteers[i].content)) {
803
+ matchedIdxs.push(i);
804
+ }
805
+ }
806
+ if (matchedIdxs.length > 0) {
807
+ const matched = matchedIdxs.map((i) => this._pendingAutosteers[i]);
808
+ // Remove from pending (reverse so earlier indices stay valid).
809
+ for (let i = matchedIdxs.length - 1; i >= 0; i--) {
810
+ this._pendingAutosteers.splice(matchedIdxs[i], 1);
811
+ }
812
+ if (!this._extraTurnState) {
813
+ this._extraTurnState = { msgIds: matched.map((m) => m.msgId), text: '' };
814
+ } else {
815
+ // rc.14.2: when the TUI dequeues multiple queued pastes
816
+ // and emits them as SEPARATE user-message events but
817
+ // produces ONE combined assistant reply (caught in spike
818
+ // multi-2-rapid: two QUEUE-dequeue events at same ms,
819
+ // two USER events sequentially, ONE ASST end_turn), the
820
+ // additional user-messages must APPEND to the in-flight
821
+ // _extraTurnState — they're all addressing the same turn.
822
+ this._extraTurnState.msgIds.push(...matched.map((m) => m.msgId));
823
+ }
824
+ for (const m of matched) {
825
+ this.emit('extra-turn-started', {
826
+ msgId: m.msgId,
827
+ sessionId: this.claudeSessionId,
828
+ backend: 'tmux',
829
+ });
830
+ this.emit('autosteer-resolution', {
831
+ msgId: m.msgId,
832
+ via: 'new-turn',
833
+ sessionId: this.claudeSessionId,
834
+ backend: 'tmux',
835
+ });
836
+ }
784
837
  } else if (this._pendingAutosteers.length > 0 && !this._turnState) {
785
838
  // user-message arrived, pending autosteer exists, no
786
- // turn in flight — but content didn't match. This is the
787
- // EXACT signature of the rc.11.1 bug (oneLine ' / ' vs
788
- // newline mismatch). Surface for forensics.
839
+ // turn in flight — but content didn't match. Forensic dump
840
+ // includes both head + tail snippets + lengths so we can
841
+ // diff identical-looking but byte-different content.
842
+ const text = ev.text || '';
843
+ const pending = this._pendingAutosteers[0];
844
+ const pContent = pending?.content || '';
789
845
  this.emit('autosteer-match-miss', {
790
846
  phase: 'user-message',
791
- text_head: (ev.text || '').slice(0, 80),
847
+ text_len: text.length,
848
+ text_head: text.slice(0, 80),
849
+ text_tail: text.slice(-80),
792
850
  pending_count: this._pendingAutosteers.length,
793
- pending_head: this._pendingAutosteers[0]
794
- ? (this._pendingAutosteers[0].content || '').slice(0, 80) : null,
851
+ pending_len: pContent.length,
852
+ pending_head: pContent.slice(0, 80),
853
+ pending_tail: pContent.slice(-80),
795
854
  sessionId: this.claudeSessionId,
796
855
  backend: 'tmux',
797
856
  });
@@ -814,6 +873,31 @@ class TmuxProcess extends Process {
814
873
  }
815
874
  }
816
875
 
876
+ /**
877
+ * rc.13.1: paste a prompt body AND press Enter as an atomic
878
+ * per-session operation. The production runner provides
879
+ * `pasteAndEnter(name, text)` which holds a per-session async lock
880
+ * across the paste+Enter pair so concurrent pasteText calls from
881
+ * a primary pm.send and a parallel injectUserMessage cannot
882
+ * interleave keystrokes in the TUI input box (root cause of the
883
+ * shumorobot 2026-05-15 reply-mis-attribution bug: msg 696's
884
+ * paste was truncated at `chat_id="-1003` when msg 698's autosteer
885
+ * paste cut in).
886
+ *
887
+ * Test runners without pasteAndEnter fall back to the sequential
888
+ * pasteText + sendControl(Enter) pair. Behaviour-equivalent for
889
+ * non-concurrent test scenarios; only production with a real tmux
890
+ * + concurrent injects exposes the race.
891
+ */
892
+ async _pasteAndEnter(text) {
893
+ if (typeof this.runner.pasteAndEnter === 'function') {
894
+ return this.runner.pasteAndEnter(this.tmuxName, text);
895
+ }
896
+ const result = await this.runner.pasteText(this.tmuxName, text);
897
+ await this.runner.sendControl(this.tmuxName, 'Enter');
898
+ return result;
899
+ }
900
+
817
901
  // ─── completion detection (§4.A capture-pane diff path — fallback) ──
818
902
 
819
903
  /**
@@ -1207,9 +1291,13 @@ class TmuxProcess extends Process {
1207
1291
  // intended extra-turn-reply path with reply_to=msg 686).
1208
1292
  const oneLine = safe.replace(/\r?\n/g, ' / ');
1209
1293
 
1210
- Promise.resolve()
1211
- .then(() => this.runner.pasteText(this.tmuxName, safe))
1212
- .then(() => this.runner.sendControl(this.tmuxName, 'Enter'))
1294
+ // rc.13.1: use pasteAndEnter so the autosteer's paste+Enter pair
1295
+ // serialises behind any in-flight pm.send paste+Enter. Without
1296
+ // the lock, the autosteer paste could interleave keystrokes into
1297
+ // the middle of a primary turn's prompt (caught on shumorobot
1298
+ // 2026-05-15: msg 696's prompt got truncated mid-attribute when
1299
+ // msg 698's autosteer paste cut in).
1300
+ this._pasteAndEnter(safe)
1213
1301
  .catch((err) => this.emit('inject-fail', { err: err.message }));
1214
1302
 
1215
1303
  // Tell the next assistant-chunk to open a fresh Telegram bubble
@@ -32,6 +32,7 @@ const childProcess = require('child_process');
32
32
  const crypto = require('crypto');
33
33
  const fs = require('fs');
34
34
  const path = require('path');
35
+ const { createAsyncLock } = require('../async-lock');
35
36
 
36
37
  // ─── Constants ───────────────────────────────────────────────────────
37
38
 
@@ -196,6 +197,32 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
196
197
  await runFn('tmux', ['send-keys', '-t', name, key]);
197
198
  }
198
199
 
200
+ // rc.13.1: paste+Enter must be ATOMIC per session. Pre-rc.13.1 two
201
+ // concurrent pasteText+sendControl pairs could interleave in the
202
+ // TUI's bracketed-paste buffer — Ivan caught this on shumorobot
203
+ // 2026-05-15 (the 2233-char user JSONL entry contained one
204
+ // truncated polygram channel + a full nested polygram prompt for
205
+ // a different msg_id). Symptom: msg 696's paste was at byte
206
+ // `chat_id="-1003` when msg 698's autosteer paste cut in,
207
+ // concatenating two pastes into one TUI user message → the agent
208
+ // saw a malformed input → the reply attribution went sideways
209
+ // (msg 697 got msg 698's answer, msg 696 got served last).
210
+ //
211
+ // The async-lock is keyed by tmux session name, so different
212
+ // sessions don't block each other. Within one session, pasteText
213
+ // + sendControl(Enter) hold the lock atomically.
214
+ const inputLock = createAsyncLock();
215
+ async function pasteAndEnter(name, text) {
216
+ const release = await inputLock.acquire(name);
217
+ try {
218
+ const res = await pasteText(name, text);
219
+ await sendControl(name, 'Enter');
220
+ return res;
221
+ } finally {
222
+ release();
223
+ }
224
+ }
225
+
199
226
  /**
200
227
  * Push a multi-line text prompt into the pane.
201
228
  *
@@ -293,6 +320,7 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
293
320
  spawn,
294
321
  sendControl,
295
322
  pasteText,
323
+ pasteAndEnter,
296
324
  capturePane,
297
325
  captureWide,
298
326
  sessionExists,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.13",
3
+ "version": "0.10.0-rc.15",
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": {