polygram 0.10.0-rc.7 → 0.10.0-rc.9

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.7",
4
+ "version": "0.10.0-rc.9",
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",
@@ -696,6 +696,17 @@ class TmuxProcess extends Process {
696
696
  const { msgId } = this._pendingAutosteers[idx];
697
697
  this._pendingAutosteers.splice(idx, 1);
698
698
  this._extraTurnState = { msgId, text: '' };
699
+ // rc.9: signal that turn 2 just started for the autosteered
700
+ // msg. Polygram subscribes to re-engage typing indicator + ✍
701
+ // reaction on msgId during the gap (clearAutosteeredReactions
702
+ // had fired when primary turn 1 succeeded under SDK-style
703
+ // fold assumption — for tmux NEW-TURN that clear was
704
+ // premature; this event lets polygram restore the visual).
705
+ this.emit('extra-turn-started', {
706
+ msgId,
707
+ sessionId: this.claudeSessionId,
708
+ backend: 'tmux',
709
+ });
699
710
  }
700
711
  } else if (ev.type === 'last-prompt') {
701
712
  // Fallback complete signal. If 'result' didn't fire (rare; some
@@ -56,6 +56,14 @@ const CALLBACK_TO_EVENT = {
56
56
  // backend never emits this — its PostToolBatch fold path
57
57
  // guarantees one combined reply.
58
58
  onExtraTurnReply: 'extra-turn-reply',
59
+ // rc.9: pair-of with onExtraTurnReply. Fires the MOMENT TmuxProcess
60
+ // sees the dequeued user-message in JSONL, which is the start of
61
+ // turn 2 — gives polygram a hook to re-engage typing indicator and
62
+ // re-apply the ✍ reaction on the autosteered msg (otherwise the
63
+ // user sees a silent gap from when clearAutosteeredReactions fired
64
+ // at primary turn 1 success until the extra-turn reply lands).
65
+ // Payload: { msgId, sessionId, backend }.
66
+ onExtraTurnStarted: 'extra-turn-started',
59
67
  };
60
68
 
61
69
  class ProcessManager {
@@ -37,6 +37,64 @@ function createSdkCallbacks({
37
37
  getThreadIdFromKey,
38
38
  logger = console,
39
39
  } = {}) {
40
+ // rc.9: typing-indicator state for autosteer NEW-TURN extraction.
41
+ // Keyed by sessionKey. extra-turn-started installs a 4-second
42
+ // sendChatAction loop + tracks the autosteered msgId; extra-turn-
43
+ // reply tears it down and clears ✍. SDK backend never installs
44
+ // entries (it doesn't emit either event). Per-session, not per-msg,
45
+ // because the TUI's queue is FIFO and we only watch one extra turn
46
+ // at a time per session.
47
+ const extraTurnTracker = new Map(); // sessionKey → { msgId, intervalHandle, chatId }
48
+
49
+ function startExtraTurnVisuals(sessionKey, msgId) {
50
+ if (!bot) return;
51
+ const chatId = getChatIdFromKey(sessionKey);
52
+ // Re-apply ✍ on the autosteered msg — clearAutosteeredReactions
53
+ // fired when primary turn 1 succeeded, so the reaction is gone.
54
+ // Best-effort; failures don't block.
55
+ tg(bot, 'setMessageReaction', {
56
+ chat_id: chatId,
57
+ message_id: msgId,
58
+ reaction: [{ type: 'emoji', emoji: '✍' }],
59
+ }, { source: 'extra-turn-started', botName }).catch((err) => {
60
+ logger.error?.(`[${botName}] extra-turn ✍ re-apply failed: ${err.message}`);
61
+ });
62
+ // Typing indicator loop — Telegram's typing action expires after
63
+ // ~5s of inactivity, so we re-emit every 4s. Stops on extra-turn-
64
+ // reply (or session close — see kill cleanup at the bottom of
65
+ // this comment chain if needed).
66
+ const tick = () => {
67
+ tg(bot, 'sendChatAction', {
68
+ chat_id: chatId,
69
+ action: 'typing',
70
+ }, { source: 'extra-turn-typing', botName }).catch(() => {});
71
+ };
72
+ tick();
73
+ const handle = setInterval(tick, 4_000);
74
+ const prev = extraTurnTracker.get(sessionKey);
75
+ if (prev?.intervalHandle) clearInterval(prev.intervalHandle);
76
+ extraTurnTracker.set(sessionKey, { msgId, intervalHandle: handle, chatId });
77
+ }
78
+
79
+ function stopExtraTurnVisuals(sessionKey, msgId) {
80
+ const entry = extraTurnTracker.get(sessionKey);
81
+ if (!entry) return;
82
+ if (entry.intervalHandle) clearInterval(entry.intervalHandle);
83
+ extraTurnTracker.delete(sessionKey);
84
+ // Clear ✍ on the autosteered msg — the reply itself is now the
85
+ // "answered" signal. Use the tracker's chatId so we don't depend
86
+ // on the caller passing it.
87
+ if (bot && entry.chatId != null) {
88
+ tg(bot, 'setMessageReaction', {
89
+ chat_id: entry.chatId,
90
+ message_id: msgId ?? entry.msgId,
91
+ reaction: [],
92
+ }, { source: 'extra-turn-reply-clear', botName }).catch((err) => {
93
+ logger.error?.(`[${botName}] extra-turn ✍ clear failed: ${err.message}`);
94
+ });
95
+ }
96
+ }
97
+
40
98
  return {
41
99
  onInit: (sessionKey, event, entry) => {
42
100
  dbWrite(() => db.upsertSession({
@@ -54,6 +112,11 @@ function createSdkCallbacks({
54
112
  onClose: (sessionKey, code, entry) => {
55
113
  logger.log?.(`[${entry.label}] Process exited (code ${code})`);
56
114
  logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
115
+ // rc.9: if a session closes mid-extra-turn (turn 2 crashed,
116
+ // user /stop, daemon kill), tear down typing-indicator + ✍
117
+ // visuals so we don't leak the interval and aren't stuck
118
+ // showing "writing…" on a dead session.
119
+ stopExtraTurnVisuals(sessionKey, null);
57
120
  },
58
121
 
59
122
  onStreamChunk: (sessionKey, partial, entry) => {
@@ -153,6 +216,30 @@ function createSdkCallbacks({
153
216
  }
154
217
  },
155
218
 
219
+ // rc.9: pair-of with onExtraTurnReply. Fires the moment
220
+ // TmuxProcess sees the dequeued user-message in JSONL → turn 2
221
+ // is starting. Re-engages typing indicator + ✍ on the
222
+ // autosteered msg so the user has a visible "still working on
223
+ // this" signal during the gap between primary turn 1 ending and
224
+ // the extra reply landing. Without this, Ivan saw a few seconds
225
+ // of nothing (✍ cleared by clearAutosteeredReactions, no
226
+ // typing).
227
+ onExtraTurnStarted: (sessionKey, payload /* , entry */) => {
228
+ try {
229
+ const msgId = payload?.msgId;
230
+ if (msgId == null) return;
231
+ startExtraTurnVisuals(sessionKey, msgId);
232
+ logEvent('extra-turn-started', {
233
+ chat_id: getChatIdFromKey(sessionKey),
234
+ session_key: sessionKey,
235
+ msg_id: msgId,
236
+ backend: payload?.backend || 'tmux',
237
+ });
238
+ } catch (err) {
239
+ logger.error?.(`[${botName}] extra-turn-started handler: ${err.message}`);
240
+ }
241
+ },
242
+
156
243
  // rc.7: tmux backend autosteer NEW-TURN extra reply. Fires when
157
244
  // the TUI's queue dequeued an autosteered paste as a fresh user
158
245
  // turn — typically when the primary turn was a short / cached
@@ -169,6 +256,10 @@ function createSdkCallbacks({
169
256
  try {
170
257
  const text = payload?.text;
171
258
  const msgId = payload?.msgId;
259
+ // rc.9: ALWAYS tear down extra-turn visuals first, even if
260
+ // text/msgId are missing — otherwise the typing-indicator
261
+ // loop would run forever for that session.
262
+ stopExtraTurnVisuals(sessionKey, msgId);
172
263
  if (!text || msgId == null) return;
173
264
  const chatId = getChatIdFromKey(sessionKey);
174
265
  const threadIdRaw = getThreadIdFromKey(sessionKey);
@@ -109,12 +109,24 @@ function parseLine(line) {
109
109
  if (obj.type === 'assistant' && obj.message) {
110
110
  const content = obj.message.content;
111
111
  if (Array.isArray(content)) {
112
+ // Cross-backend parity (rc.8): SDK's extractAssistantText
113
+ // (lib/process/sdk-process.js:62-73) joins all text blocks of
114
+ // one assistant message into a single string and applies a
115
+ // trailing-colon → ellipsis transform: "Listing deps:" →
116
+ // "Listing deps…". This keeps streamed-but-not-final text from
117
+ // reading as half-formed during the pause while a tool runs.
118
+ // The CLI/tmux backend previously emitted one assistant-chunk
119
+ // per text block with no normalisation — Telegram bubbles
120
+ // would briefly show "files are in place:" while the agent
121
+ // ran follow-up tools. Now we mirror SDK byte-for-byte.
122
+ const textParts = [];
123
+ const toolUses = [];
112
124
  for (const block of content) {
113
125
  if (!block || typeof block !== 'object') continue;
114
126
  if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) {
115
- out.push({ type: 'assistant-chunk', text: block.text });
127
+ textParts.push(block.text);
116
128
  } else if (block.type === 'tool_use' && block.name) {
117
- out.push({
129
+ toolUses.push({
118
130
  type: 'tool-use',
119
131
  name: block.name,
120
132
  input: block.input ?? null,
@@ -122,6 +134,16 @@ function parseLine(line) {
122
134
  });
123
135
  }
124
136
  }
137
+ // Emit text FIRST then tool-uses — matches the pre-rc.8 order
138
+ // (when the per-block iteration interleaved them in source
139
+ // order, but text-then-tool is the dominant real-world shape).
140
+ if (textParts.length > 0) {
141
+ const joined = textParts.join('\n\n').trim().replace(/([^:]):\s*$/, '$1…');
142
+ if (joined.length > 0) {
143
+ out.push({ type: 'assistant-chunk', text: joined });
144
+ }
145
+ }
146
+ for (const t of toolUses) out.push(t);
125
147
  }
126
148
  // Token-usage telemetry. Every assistant message carries the
127
149
  // cumulative usage snapshot — input_tokens + cache_creation +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.7",
3
+ "version": "0.10.0-rc.9",
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": {