polygram 0.10.0-rc.6 → 0.10.0-rc.7

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.6",
4
+ "version": "0.10.0-rc.7",
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",
@@ -69,9 +69,15 @@ function createAutosteerHandlers({
69
69
  if (!entry?.inFlight) return { autosteered: false };
70
70
 
71
71
  const priority = priorityFor(chatConfig, config);
72
+ // rc.7: pass the autosteered msg_id through to the backend so the
73
+ // tmux backend can route an extra-turn reply back to Telegram if
74
+ // the TUI dequeues the paste as a fresh user turn (NEW-TURN path).
75
+ // SDK backend ignores msgId — its PostToolBatch fold path
76
+ // guarantees one combined reply via the primary pm.send.
72
77
  const ok = pm.injectUserMessage(sessionKey, {
73
78
  content: prompt,
74
79
  priority,
80
+ msgId: msg.message_id,
75
81
  });
76
82
  if (!ok) return { autosteered: false };
77
83
 
@@ -167,6 +167,29 @@ class TmuxProcess extends Process {
167
167
  // getContextUsage() so polygram's post-turn auto-hint works on
168
168
  // the tmux backend just like SDK.
169
169
  this._lastUsage = null;
170
+
171
+ // rc.7: autosteer fold-vs-queue tracking. When polygram autosteers
172
+ // a user message via injectUserMessage({content, msgId}), we
173
+ // record (content, msgId) here. The TUI's queue may consume that
174
+ // paste in two ways (see SessionLogParser docs):
175
+ //
176
+ // A) FOLD — JSONL emits `attachment.type="queued_command"`.
177
+ // We pop the matching entry. The primary turn's reply
178
+ // already covers both messages — no extra delivery needed.
179
+ //
180
+ // B) NEW TURN — JSONL emits a top-level `user` message with
181
+ // matching content. We pop the matching entry and start an
182
+ // `_extraTurnState` accumulator. Subsequent assistant-chunks
183
+ // (while no _turnState is active — i.e. primary pm.send
184
+ // already returned) feed the accumulator; the next `result`
185
+ // event flushes it as an `'extra-turn-reply'` { msgId, text }
186
+ // event for polygram to route to Telegram.
187
+ //
188
+ // FIFO matching by content text — multiple pending autosteers are
189
+ // resolved in submission order, which matches how Telegram
190
+ // delivers messages anyway.
191
+ this._pendingAutosteers = []; // Array<{ content, msgId }>
192
+ this._extraTurnState = null; // null | { msgId, text }
170
193
  }
171
194
 
172
195
  get cost() { return 3; }
@@ -468,6 +491,14 @@ class TmuxProcess extends Process {
468
491
 
469
492
  _completeTurn() {
470
493
  this.inFlight = false;
494
+ // rc.7: clear _turnState so JSONL events arriving BETWEEN turns
495
+ // (autonomous assistant messages, queue-dequeued user prompts
496
+ // becoming fresh turns) don't incorrectly route to the just-
497
+ // completed turn. Without this, the rc.7 autosteer extra-turn
498
+ // path can't distinguish "primary turn done, watch for queue
499
+ // dequeue" from "still in primary turn". The next send() call
500
+ // freshly sets _turnState so existing callers are unaffected.
501
+ this._turnState = null;
471
502
  // Shift the HEAD pending (just-completed turn). After this, the
472
503
  // queue contains only items queued while inFlight (each carrying
473
504
  // their own resolve/reject pair). If any, re-enter send() on the
@@ -560,6 +591,13 @@ class TmuxProcess extends Process {
560
591
  ? `${this._turnState.text}\n\n${ev.text}`
561
592
  : ev.text;
562
593
  this.emit('stream-chunk', this._turnState.text);
594
+ } else if (this._extraTurnState) {
595
+ // rc.7: autosteer NEW-TURN extraction in progress. Accumulate
596
+ // this chunk into the extra-turn buffer. It will be flushed
597
+ // when the next 'result' event fires (see below).
598
+ this._extraTurnState.text = this._extraTurnState.text
599
+ ? `${this._extraTurnState.text}\n\n${ev.text}`
600
+ : ev.text;
563
601
  } else {
564
602
  // No turn in flight — this is an autonomous assistant message
565
603
  // (claude self-initiated; typically ScheduleWakeup firing).
@@ -614,10 +652,51 @@ class TmuxProcess extends Process {
614
652
  if (this._turnState && this._turnState.resolveResult) {
615
653
  this._turnState.resultEvent = ev;
616
654
  this._turnState.resolveResult(ev);
655
+ } else if (this._extraTurnState) {
656
+ // rc.7: extra-turn (autosteer NEW-TURN) just completed. Flush
657
+ // the accumulated text as 'extra-turn-reply' so polygram can
658
+ // send it to Telegram as a reply to the autosteered msgId.
659
+ // Only flush on the 'success' subtype (end_turn) — non-terminal
660
+ // result subtypes like 'tool_use' shouldn't close the
661
+ // extra-turn (the model may still emit more chunks).
662
+ if (ev.subtype === 'success') {
663
+ const flushed = this._extraTurnState;
664
+ this._extraTurnState = null;
665
+ this.emit('extra-turn-reply', {
666
+ msgId: flushed.msgId,
667
+ text: flushed.text,
668
+ sessionId: this.claudeSessionId,
669
+ backend: 'tmux',
670
+ });
671
+ }
672
+ }
673
+ // If no turn in flight and no extra-turn, result event just
674
+ // marks the end of an autonomous message segment — already
675
+ // handled by the assistant-chunk branch above.
676
+ } else if (ev.type === 'queue-folded') {
677
+ // rc.7: TUI consumed an autosteered paste inside the current
678
+ // turn (FOLD). Pop the matching pending autosteer so a later
679
+ // 'user-message' with the same content doesn't spuriously
680
+ // trigger extra-turn extraction. The primary turn's reply
681
+ // already covers both messages — no extra delivery needed.
682
+ const idx = this._pendingAutosteers.findIndex((a) => a.content === ev.prompt);
683
+ if (idx >= 0) {
684
+ this._pendingAutosteers.splice(idx, 1);
685
+ }
686
+ } else if (ev.type === 'user-message') {
687
+ // rc.7: top-level user message in JSONL — may be the TUI
688
+ // dequeuing an autosteered paste as a fresh user turn
689
+ // (NEW-TURN path). Match by content. If found AND no primary
690
+ // turn is currently in flight (the autosteered second turn
691
+ // runs AFTER the first turn returned), start an extra-turn
692
+ // accumulator. Subsequent assistant-chunks + the next 'result'
693
+ // event will flush it as 'extra-turn-reply'.
694
+ const idx = this._pendingAutosteers.findIndex((a) => a.content === ev.text);
695
+ if (idx >= 0 && !this._turnState && !this._extraTurnState) {
696
+ const { msgId } = this._pendingAutosteers[idx];
697
+ this._pendingAutosteers.splice(idx, 1);
698
+ this._extraTurnState = { msgId, text: '' };
617
699
  }
618
- // If no turn in flight, the result event simply marks the end of
619
- // an autonomous message segment — already handled by the
620
- // assistant-chunk branch above.
621
700
  } else if (ev.type === 'last-prompt') {
622
701
  // Fallback complete signal. If 'result' didn't fire (rare; some
623
702
  // claude versions may write last-prompt instead of stop_reason),
@@ -996,10 +1075,15 @@ class TmuxProcess extends Process {
996
1075
  * Inject text into the in-flight turn. Fire-and-forget paste; errors
997
1076
  * surface via 'inject-fail' event, never as a thrown exception.
998
1077
  *
1078
+ * @param {object} [opts.msgId] — rc.7: when provided, registers
1079
+ * the (content, msgId) for autosteer fold-vs-queue tracking so
1080
+ * the NEW-TURN case (queue dequeued as a fresh user turn) can
1081
+ * route its reply back to Telegram via 'extra-turn-reply'.
1082
+ *
999
1083
  * @returns {boolean} false if no live turn (caller falls through to
1000
1084
  * pm.send queue path) OR if content sanitized to empty.
1001
1085
  */
1002
- injectUserMessage({ content, priority = 'next', shouldQuery } = {}) {
1086
+ injectUserMessage({ content, priority = 'next', shouldQuery, msgId } = {}) {
1003
1087
  if (!this.inFlight || this.closed) return false;
1004
1088
  // Mirror R2-F1: sanitize even though pasteText also sanitizes.
1005
1089
  // We need to detect empty-after-sanitize here so caller can fall
@@ -1020,7 +1104,17 @@ class TmuxProcess extends Process {
1020
1104
  this._turnState.pendingSteerCausesNewBubble = true;
1021
1105
  }
1022
1106
 
1023
- this.emit('inject-user-message', { text_len: safe.length, priority, shouldQuery });
1107
+ // rc.7: register the autosteer for fold-vs-queue tracking. The
1108
+ // JSONL parser will later emit either 'queue-folded' (FOLD) or
1109
+ // 'user-message' (NEW TURN) with the same content, and
1110
+ // _handleSessionEvent matches by content to resolve which path
1111
+ // ran. msgId is optional — when omitted, the inject is still
1112
+ // delivered but no extra-turn extraction is attempted.
1113
+ if (msgId != null) {
1114
+ this._pendingAutosteers.push({ content: safe, msgId });
1115
+ }
1116
+
1117
+ this.emit('inject-user-message', { text_len: safe.length, priority, shouldQuery, msgId });
1024
1118
  return true;
1025
1119
  }
1026
1120
 
@@ -48,6 +48,14 @@ const CALLBACK_TO_EVENT = {
48
48
  // onApprovalRequired to route tmux prompts through the SAME
49
49
  // approval card UI used by SDK's canUseTool flow.
50
50
  onApprovalRequired: 'approval-required',
51
+ // rc.7: tmux backend autosteer "NEW-TURN" extra reply. Fires when
52
+ // the TUI's queue dequeued an autosteered paste as a fresh user
53
+ // turn (because primary turn ended before fold could happen). The
54
+ // payload is { msgId, text, sessionId, backend } and polygram
55
+ // routes it to Telegram as a reply to the autosteered msgId. SDK
56
+ // backend never emits this — its PostToolBatch fold path
57
+ // guarantees one combined reply.
58
+ onExtraTurnReply: 'extra-turn-reply',
51
59
  };
52
60
 
53
61
  class ProcessManager {
@@ -153,6 +153,54 @@ function createSdkCallbacks({
153
153
  }
154
154
  },
155
155
 
156
+ // rc.7: tmux backend autosteer NEW-TURN extra reply. Fires when
157
+ // the TUI's queue dequeued an autosteered paste as a fresh user
158
+ // turn — typically when the primary turn was a short / cached
159
+ // reply that finished before the paste could fold in. The
160
+ // payload carries { msgId, text, sessionId, backend }; msgId is
161
+ // the Telegram message_id of the autosteered user message
162
+ // (so the reply lands as a Telegram reply to that message,
163
+ // matching how Ivan visually expects autosteer to behave).
164
+ //
165
+ // SDK backend NEVER emits this — its PostToolBatch fold path
166
+ // guarantees one combined reply via the primary pm.send().
167
+ // This is purely a tmux-backend bridge.
168
+ onExtraTurnReply: (sessionKey, payload /* , entry */) => {
169
+ try {
170
+ const text = payload?.text;
171
+ const msgId = payload?.msgId;
172
+ if (!text || msgId == null) return;
173
+ const chatId = getChatIdFromKey(sessionKey);
174
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
175
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
176
+ if (!bot) {
177
+ logger.error?.(`[${botName}] extra-turn-reply: bot not ready, dropping ${text.length} chars`);
178
+ return;
179
+ }
180
+ const params = {
181
+ chat_id: chatId,
182
+ text,
183
+ reply_to_message_id: msgId,
184
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
185
+ };
186
+ // Don't await — keep the pm event loop unblocked.
187
+ tg(bot, 'sendMessage', params,
188
+ { source: 'extra-turn-reply', botName }).catch((err) => {
189
+ logger.error?.(`[${botName}] extra-turn-reply send failed: ${err.message}`);
190
+ });
191
+ logEvent('extra-turn-reply', {
192
+ chat_id: chatId,
193
+ session_key: sessionKey,
194
+ thread_id: threadIdRaw,
195
+ msg_id: msgId,
196
+ text_len: text.length,
197
+ backend: payload?.backend || 'tmux',
198
+ });
199
+ } catch (err) {
200
+ logger.error?.(`[${botName}] extra-turn-reply handler: ${err.message}`);
201
+ }
202
+ },
203
+
156
204
  // SDK auto-compaction observability. Fires when SDK emits
157
205
  // SDKCompactBoundaryMessage. Surfaces a quiet system status note
158
206
  // to the chat so the user knows the bot is busy reorganising
@@ -24,6 +24,16 @@
24
24
  * - assistant with `content[].type === 'tool_use'` → emit 'tool-use' { name, input }
25
25
  * - assistant with `message.stop_reason` → emit 'result' { subtype, text, ... }
26
26
  * - last-prompt → emit 'last-prompt' (fallback complete signal)
27
+ * - user (top-level, content is string) → emit 'user-message' { text }
28
+ * The queue-becomes-new-turn signal: when the TUI dequeues a
29
+ * queued paste as a fresh user turn (because the prior turn
30
+ * ended before fold could happen). Note: when content is an
31
+ * ARRAY containing a `tool_result` block, that's API-shaped
32
+ * tool_result feedback (not a user prompt) — must NOT emit.
33
+ * - attachment with `attachment.type === 'queued_command'` → emit 'queue-folded' { prompt }
34
+ * The fold-confirmed signal: a paste IS being consumed inside
35
+ * the current turn. Polygram uses this to know it does NOT
36
+ * need to extract a second reply for the autosteered msg.
27
37
  *
28
38
  * Robust against malformed lines: returns null and skips.
29
39
  *
@@ -147,6 +157,23 @@ function parseLine(line) {
147
157
  }
148
158
  } else if (obj.type === 'last-prompt') {
149
159
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
160
+ } else if (obj.type === 'user' && obj.message) {
161
+ // Top-level user message — only emit when content is a non-empty
162
+ // string. Array content carries tool_result blocks (API-shaped
163
+ // tool feedback), which are NOT user prompts and must be skipped
164
+ // — they would otherwise trigger spurious "extra-turn" extraction
165
+ // in TmuxProcess.
166
+ const content = obj.message.content;
167
+ if (typeof content === 'string' && content.length > 0) {
168
+ out.push({ type: 'user-message', text: content });
169
+ }
170
+ } else if (obj.type === 'attachment' && obj.attachment) {
171
+ // queued_command attachment is the FOLD signal — the TUI consumed
172
+ // an autosteered paste inside the current in-flight turn.
173
+ const a = obj.attachment;
174
+ if (a.type === 'queued_command' && typeof a.prompt === 'string' && a.prompt.length > 0) {
175
+ out.push({ type: 'queue-folded', prompt: a.prompt });
176
+ }
150
177
  }
151
178
 
152
179
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.6",
3
+ "version": "0.10.0-rc.7",
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": {