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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/tmux-process.js +138 -50
- package/lib/tmux/tmux-runner.js +28 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
this.
|
|
779
|
-
msgId,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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.
|
|
787
|
-
//
|
|
788
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
package/lib/tmux/tmux-runner.js
CHANGED
|
@@ -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.
|
|
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": {
|