polygram 0.10.0-rc.2 → 0.10.0-rc.21

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.
@@ -48,6 +48,37 @@ 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',
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',
67
+ // rc.11.1: observability — autosteer resolution + match-miss events.
68
+ // - autosteer-resolution: { msgId, via: 'fold'|'new-turn' } —
69
+ // confirms which JSONL signal resolved each autosteered msgId.
70
+ // - autosteer-match-miss: { phase, *_head, pending_count } —
71
+ // fires when JSONL has the queue/dequeue signal but content
72
+ // doesn't match any pending. The signature of the rc.11.1 bug
73
+ // (oneLine ' / ' vs newline mismatch) would have shown up here.
74
+ onAutosteerResolution: 'autosteer-resolution',
75
+ onAutosteerMatchMiss: 'autosteer-match-miss',
76
+ // R8: tmux backend autosteer paste failure. TmuxProcess.injectUserMessage
77
+ // fires `inject-fail` when its fire-and-forget paste rejects. Before
78
+ // this was wired the event had no consumer — a failed autosteer was
79
+ // silent until the stale-turn sweep caught it turnTimeoutMs later.
80
+ // The handler logs the failure and clears the ✍ on the failed msgId.
81
+ onInjectFail: 'inject-fail',
51
82
  };
52
83
 
53
84
  class ProcessManager {
@@ -82,6 +113,10 @@ class ProcessManager {
82
113
  this.procs = new Map(); // sessionKey → Process
83
114
  this._lruWaiters = []; // [{ resolve, reject, timer }]
84
115
  this._shuttingDown = false;
116
+ // sessionKey → in-flight start() Promise. Lets a concurrent
117
+ // getOrSpawn for the same key await the spawn instead of
118
+ // returning a proc whose start() hasn't resolved (see getOrSpawn).
119
+ this._starting = new Map();
85
120
  }
86
121
 
87
122
  // ─── Introspection ───────────────────────────────────────────────
@@ -116,7 +151,20 @@ class ProcessManager {
116
151
  if (this._shuttingDown) throw new Error('shutdown');
117
152
 
118
153
  const existing = this.procs.get(sessionKey);
119
- if (existing && !existing.closed) return existing;
154
+ if (existing && !existing.closed) {
155
+ // getOrSpawn registers the proc in this.procs BEFORE awaiting
156
+ // start(). A concurrent getOrSpawn for the same key (a second
157
+ // Telegram message landing during the ~11s tmux spawn) would
158
+ // otherwise get this still-spawning proc and call send() on it
159
+ // — pasting a turn into a TUI that is not ready, which silently
160
+ // drops the paste and returns an empty turn (shumorobot
161
+ // production 2026-05-16: msg 2 of a 3-message burst returned
162
+ // "No response generated"). Await the in-flight spawn so every
163
+ // caller receives a proc whose start() has fully resolved.
164
+ const pendingStart = this._starting.get(sessionKey);
165
+ if (pendingStart) await pendingStart;
166
+ return existing;
167
+ }
120
168
 
121
169
  // Provisional new-process cost — ask the factory but don't start yet.
122
170
  const newProc = this.processFactory(sessionKey, spawnContext);
@@ -138,11 +186,17 @@ class ProcessManager {
138
186
  this._wireCallbacks(newProc);
139
187
  this.procs.set(sessionKey, newProc);
140
188
  newProc.lastUsedTs = Date.now();
189
+ // Publish the in-flight start() Promise so concurrent getOrSpawn
190
+ // callers (above) can await it instead of racing the spawn.
191
+ const startP = newProc.start(spawnContext);
192
+ this._starting.set(sessionKey, startP);
141
193
  try {
142
- await newProc.start(spawnContext);
194
+ await startP;
143
195
  } catch (err) {
144
196
  this.procs.delete(sessionKey);
145
197
  throw err;
198
+ } finally {
199
+ this._starting.delete(sessionKey);
146
200
  }
147
201
  return newProc;
148
202
  }
@@ -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,162 @@ 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
+
243
+ // rc.11.1: autosteer-resolution telemetry. Fires when the JSONL
244
+ // tail confirms which path the autosteer resolved through:
245
+ // - via:'fold' — TUI absorbed the paste as queued_command
246
+ // attachment inside the current turn (one combined reply).
247
+ // - via:'new-turn' — TUI dequeued as a fresh user turn
248
+ // (extra-turn-reply pathway).
249
+ // Querying `kind='autosteer-resolution'` in the events DB gives
250
+ // a complete audit trail of every autosteer's fate.
251
+ onAutosteerResolution: (sessionKey, payload /* , entry */) => {
252
+ try {
253
+ logEvent('autosteer-resolution', {
254
+ chat_id: getChatIdFromKey(sessionKey),
255
+ session_key: sessionKey,
256
+ msg_id: payload?.msgId,
257
+ via: payload?.via,
258
+ backend: payload?.backend || 'tmux',
259
+ });
260
+ } catch (err) {
261
+ logger.error?.(`[${botName}] autosteer-resolution handler: ${err.message}`);
262
+ }
263
+ },
264
+
265
+ // rc.11.1: autosteer-match-miss telemetry. Fires when JSONL has
266
+ // a queue-folded or top-level user-message that LOOKS LIKE an
267
+ // autosteer dequeue but no pending content matched. This is the
268
+ // signature of a content-encoding mismatch (the exact rc.11.1
269
+ // bug — oneLine ' / ' vs newline form). The payload includes
270
+ // head-snippets of both sides for diff-by-eye in the events DB.
271
+ onAutosteerMatchMiss: (sessionKey, payload /* , entry */) => {
272
+ try {
273
+ logEvent('autosteer-match-miss', {
274
+ chat_id: getChatIdFromKey(sessionKey),
275
+ session_key: sessionKey,
276
+ phase: payload?.phase,
277
+ text_head: payload?.text_head ?? payload?.prompt_head,
278
+ pending_head: payload?.pending_head,
279
+ pending_count: payload?.pending_count,
280
+ backend: payload?.backend || 'tmux',
281
+ });
282
+ } catch (err) {
283
+ logger.error?.(`[${botName}] autosteer-match-miss handler: ${err.message}`);
284
+ }
285
+ },
286
+
287
+ // R8: a failed autosteer paste. injectUserMessage fires
288
+ // `inject-fail` when its fire-and-forget paste rejects (tmux
289
+ // server gone, paste-buffer error, etc.). Before this handler was
290
+ // wired the event had NO consumer — a failed autosteer was silent
291
+ // until the stale-turn sweep caught it `turnTimeoutMs` later, so
292
+ // the ✍ reaction sat on the message for up to 5 minutes with no
293
+ // reply coming. This surfaces it immediately: log the failure for
294
+ // diagnosis and clear the ✍ on the autosteered msgId so the user
295
+ // is not left looking at a "noted, working on it" signal for a
296
+ // message that never reached the agent. tmux-only — the SDK
297
+ // backend's injectUserMessage never emits inject-fail.
298
+ onInjectFail: (sessionKey, payload /* , entry */) => {
299
+ try {
300
+ const msgId = payload?.msgId;
301
+ logEvent('inject-fail', {
302
+ chat_id: getChatIdFromKey(sessionKey),
303
+ session_key: sessionKey,
304
+ msg_id: msgId ?? null,
305
+ error: (payload?.err || '').slice(0, 200),
306
+ backend: payload?.backend || 'tmux',
307
+ });
308
+ if (bot && msgId != null) {
309
+ const chatId = getChatIdFromKey(sessionKey);
310
+ tg(bot, 'setMessageReaction', {
311
+ chat_id: chatId,
312
+ message_id: msgId,
313
+ reaction: [],
314
+ }, { source: 'inject-fail-clear', botName }).catch((err) => {
315
+ logger.error?.(`[${botName}] inject-fail ✍ clear failed: ${err.message}`);
316
+ });
317
+ }
318
+ } catch (err) {
319
+ logger.error?.(`[${botName}] inject-fail handler: ${err.message}`);
320
+ }
321
+ },
322
+
323
+ // rc.7: tmux backend autosteer NEW-TURN extra reply. Fires when
324
+ // the TUI's queue dequeued an autosteered paste as a fresh user
325
+ // turn — typically when the primary turn was a short / cached
326
+ // reply that finished before the paste could fold in. The
327
+ // payload carries { msgId, text, sessionId, backend }; msgId is
328
+ // the Telegram message_id of the autosteered user message
329
+ // (so the reply lands as a Telegram reply to that message,
330
+ // matching how Ivan visually expects autosteer to behave).
331
+ //
332
+ // SDK backend NEVER emits this — its PostToolBatch fold path
333
+ // guarantees one combined reply via the primary pm.send().
334
+ // This is purely a tmux-backend bridge.
335
+ onExtraTurnReply: (sessionKey, payload /* , entry */) => {
336
+ try {
337
+ const text = payload?.text;
338
+ const msgId = payload?.msgId;
339
+ // rc.9: ALWAYS tear down extra-turn visuals first, even if
340
+ // text/msgId are missing — otherwise the typing-indicator
341
+ // loop would run forever for that session.
342
+ stopExtraTurnVisuals(sessionKey, msgId);
343
+ if (!text || msgId == null) return;
344
+ const chatId = getChatIdFromKey(sessionKey);
345
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
346
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
347
+ if (!bot) {
348
+ logger.error?.(`[${botName}] extra-turn-reply: bot not ready, dropping ${text.length} chars`);
349
+ return;
350
+ }
351
+ const params = {
352
+ chat_id: chatId,
353
+ text,
354
+ reply_to_message_id: msgId,
355
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
356
+ };
357
+ // Don't await — keep the pm event loop unblocked.
358
+ tg(bot, 'sendMessage', params,
359
+ { source: 'extra-turn-reply', botName }).catch((err) => {
360
+ logger.error?.(`[${botName}] extra-turn-reply send failed: ${err.message}`);
361
+ });
362
+ logEvent('extra-turn-reply', {
363
+ chat_id: chatId,
364
+ session_key: sessionKey,
365
+ thread_id: threadIdRaw,
366
+ msg_id: msgId,
367
+ text_len: text.length,
368
+ backend: payload?.backend || 'tmux',
369
+ });
370
+ } catch (err) {
371
+ logger.error?.(`[${botName}] extra-turn-reply handler: ${err.message}`);
372
+ }
373
+ },
374
+
156
375
  // SDK auto-compaction observability. Fires when SDK emits
157
376
  // SDKCompactBoundaryMessage. Surfaces a quiet system status note
158
377
  // to the chat so the user knows the bot is busy reorganising