polygram 0.12.0-rc.8 → 0.12.0

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.
Files changed (46) hide show
  1. package/config.example.json +4 -3
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -50,65 +50,23 @@ function createSdkCallbacks({
50
50
  chunkMarkdownText,
51
51
  deliverReplies,
52
52
  processAndDeliverAgentText,
53
+ // 0.12 interactive questions: (payload) => renders the Telegram keyboard when
54
+ // claude calls the `ask` tool. Optional — omitted in tests / SDK-only callers.
55
+ renderQuestion,
56
+ // 0.13 D3: session-scoped feedback controller (lib/feedback/session-feedback.js)
57
+ // — visuals for cycles with NO pending turn (wakeups, fireUserMessage
58
+ // self-checks, injected messages picked up as their own cycle). Optional.
59
+ sessionFeedback = null,
53
60
  logger = console,
54
61
  } = {}) {
55
- // rc.9: typing-indicator state for autosteer NEW-TURN extraction.
56
- // Keyed by sessionKey. extra-turn-started installs a 4-second
57
- // sendChatAction loop + tracks the autosteered msgId; extra-turn-
58
- // reply tears it down and clears ✍. SDK backend never installs
59
- // entries (it doesn't emit either event). Per-session, not per-msg,
60
- // because the TUI's queue is FIFO and we only watch one extra turn
61
- // at a time per session.
62
- const extraTurnTracker = new Map(); // sessionKey → { msgId, intervalHandle, chatId }
63
-
64
- function startExtraTurnVisuals(sessionKey, msgId) {
65
- if (!bot) return;
66
- const chatId = getChatIdFromKey(sessionKey);
67
- // Re-apply ✍ on the autosteered msg — clearAutosteeredReactions
68
- // fired when primary turn 1 succeeded, so the reaction is gone.
69
- // Best-effort; failures don't block.
70
- tg(bot, 'setMessageReaction', {
71
- chat_id: chatId,
72
- message_id: msgId,
73
- reaction: [{ type: 'emoji', emoji: '✍' }],
74
- }, { source: 'extra-turn-started', botName }).catch((err) => {
75
- logger.error?.(`[${botName}] extra-turn ✍ re-apply failed: ${err.message}`);
76
- });
77
- // Typing indicator loop — Telegram's typing action expires after
78
- // ~5s of inactivity, so we re-emit every 4s. Stops on extra-turn-
79
- // reply (or session close — see kill cleanup at the bottom of
80
- // this comment chain if needed).
81
- const tick = () => {
82
- tg(bot, 'sendChatAction', {
83
- chat_id: chatId,
84
- action: 'typing',
85
- }, { source: 'extra-turn-typing', botName }).catch(() => {});
86
- };
87
- tick();
88
- const handle = setInterval(tick, 4_000);
89
- const prev = extraTurnTracker.get(sessionKey);
90
- if (prev?.intervalHandle) clearInterval(prev.intervalHandle);
91
- extraTurnTracker.set(sessionKey, { msgId, intervalHandle: handle, chatId });
92
- }
93
-
94
- function stopExtraTurnVisuals(sessionKey, msgId) {
95
- const entry = extraTurnTracker.get(sessionKey);
96
- if (!entry) return;
97
- if (entry.intervalHandle) clearInterval(entry.intervalHandle);
98
- extraTurnTracker.delete(sessionKey);
99
- // Clear ✍ on the autosteered msg — the reply itself is now the
100
- // "answered" signal. Use the tracker's chatId so we don't depend
101
- // on the caller passing it.
102
- if (bot && entry.chatId != null) {
103
- tg(bot, 'setMessageReaction', {
104
- chat_id: entry.chatId,
105
- message_id: msgId ?? entry.msgId,
106
- reaction: [],
107
- }, { source: 'extra-turn-reply-clear', botName }).catch((err) => {
108
- logger.error?.(`[${botName}] extra-turn ✍ clear failed: ${err.message}`);
109
- });
110
- }
111
- }
62
+ // 0.13 P4: the rc.9 extraTurnTracker (tmux NEW-TURN typing/✍ bridge) was
63
+ // deleted zero 'extra-turn-started'/'extra-turn-reply' emitters exist on
64
+ // any backend since the 0.12 tmux deletion. Cycles with no pending turn
65
+ // are owned by the session feedback controller (lib/feedback/) now.
66
+ // 0.12.0 background-work visibility (Use 3): sessionKey message_id of the live
67
+ // "⏳ working in background" status message, so the cleared/close paths can edit
68
+ // it to a final state instead of leaving it dangling as "working".
69
+ const bgStatusMsgIds = new Map();
112
70
 
113
71
  return {
114
72
  onInit: (sessionKey, event, entry) => {
@@ -147,11 +105,21 @@ function createSdkCallbacks({
147
105
  onClose: (sessionKey, code, entry) => {
148
106
  logger.log?.(`[${entry.label}] Process exited (code ${code})`);
149
107
  logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
150
- // rc.9: if a session closes mid-extra-turn (turn 2 crashed,
151
- // user /stop, daemon kill), tear down typing-indicator + ✍
152
- // visuals so we don't leak the interval and aren't stuck
153
- // showing "writing…" on a dead session.
154
- stopExtraTurnVisuals(sessionKey, null);
108
+ // 0.13 D3: a session closing mid-autonomous-cycle must tear down the
109
+ // controller's visuals (typing loop + anchor reaction) the safety net
110
+ // against a forever-typing leak on a dead session.
111
+ sessionFeedback?.endCycle(sessionKey);
112
+ // 0.12.0 bg-work visibility: if a "⏳ working in background" status is still
113
+ // up when the session closes, its shell died with the session — edit to a
114
+ // final state so it doesn't dangle as "working" forever.
115
+ const bgMid = bgStatusMsgIds.get(sessionKey);
116
+ if (bgMid != null && bot) {
117
+ bgStatusMsgIds.delete(sessionKey);
118
+ tg(bot, 'editMessageText', {
119
+ chat_id: entry.chatId, message_id: bgMid,
120
+ text: '⏹ Background work ended (session restarted).',
121
+ }, { source: 'bg-work-status', botName }).catch(() => {});
122
+ }
155
123
  },
156
124
 
157
125
  onStreamChunk: (sessionKey, partial, entry) => {
@@ -179,6 +147,13 @@ function createSdkCallbacks({
179
147
  const head = entry.pendingQueue?.[0];
180
148
  const r = head?.context?.reactor;
181
149
  if (r && typeof r.heartbeat === 'function') r.heartbeat();
150
+ // 0.13 D3 (the voice-ack gap): on cli, onFirstStream never fires, so a
151
+ // turn whose reactor was never set (👂 voice-ack held, THINKING skipped)
152
+ // stayed on the ear forever on tool-less turns. The pane heartbeat is
153
+ // the first life sign — promote ONCE from never-set; later polls no-op.
154
+ if (r && typeof r.setState === 'function' && r.currentState == null) {
155
+ r.setState('THINKING');
156
+ }
182
157
  },
183
158
 
184
159
  onToolUse: (sessionKey, toolName, entry) => {
@@ -324,71 +299,110 @@ function createSdkCallbacks({
324
299
  }
325
300
  },
326
301
 
327
- // rc.9: pair-of with onExtraTurnReply. Fires the moment
328
- // TmuxProcess sees the dequeued user-message in JSONL turn 2
329
- // is starting. Re-engages typing indicator + on the
330
- // autosteered msg so the user has a visible "still working on
331
- // this" signal during the gap between primary turn 1 ending and
332
- // the extra reply landing. Without this, Ivan saw a few seconds
333
- // of nothing (✍ cleared by clearAutosteeredReactions, no
334
- // typing).
335
- onExtraTurnStarted: (sessionKey, payload /* , entry */) => {
302
+ // 0.12.0 background-work visibility (Use 3). CliProcess emits this when a
303
+ // detached `run_in_background` shell is first observed running idle past its
304
+ // turn ('running') and again when it clears ('cleared'). We post ONE bot
305
+ // status message and edit it to done — so a long job reads as working, not
306
+ // stuck. Direct tg send (NOT via claude this is a bot status indicator),
307
+ // keyed by sessionKey so the cleared/close paths can find it to edit.
308
+ // 0.12 interactive questions: claude called the `ask` tool. Render the
309
+ // Telegram inline keyboard via the question handler (late-bound from polygram).
310
+ // payload: {chatId, threadId, turnId, toolCallId, questions}. The handler
311
+ // itself is anti-hang (answers claude {cancelled} on any send failure).
312
+ // 0.12 interactive questions: the blocking `ask` resolved → the turn is resuming work. The
313
+ // per-turn reactor cleared when claude posted its reply + asked, and no hooks fired during
314
+ // the wait, so it never came back — the post-answer work showed no progress ("why don't I
315
+ // see it working after submit?"). Re-arm the head pending's reactor to THINKING. setState is
316
+ // a safe no-op if the reactor was stopped; typing is unaffected (its per-turn loop runs to
317
+ // turn-end). Guarded — never throws on a torn-down turn.
318
+ // 0.13 D3: 'turn-start' (UPS) finally consumed. A pickup with NO pending
319
+ // turn is an autonomous/injected cycle starting — pre-P4 nothing showed
320
+ // until text landed. Engage the session feedback controller (typing +
321
+ // optional anchor 🤔 on the picked-up message, which the ledger names).
322
+ onTurnStart: (sessionKey, payload, entry) => {
336
323
  try {
337
- const msgId = payload?.msgId;
338
- if (msgId == null) return;
339
- startExtraTurnVisuals(sessionKey, msgId);
340
- logEvent('extra-turn-started', {
341
- chat_id: getChatIdFromKey(sessionKey),
342
- session_key: sessionKey,
343
- msg_id: msgId,
344
- backend: payload?.backend || 'tmux',
345
- });
324
+ if (!sessionFeedback) return;
325
+ const hasPending = payload?.hasPending ?? (entry?.pendingQueue?.length > 0);
326
+ if (hasPending) return; // normal turns own their per-turn visuals
327
+ sessionFeedback.startAutonomousCycle(sessionKey, { anchorMsgId: payload?.anchorMsgId ?? null });
346
328
  } catch (err) {
347
- logger.error?.(`[${botName}] extra-turn-started handler: ${err.message}`);
329
+ logger.error?.(`[${botName}] onTurnStart failed: ${err.message}`);
348
330
  }
349
331
  },
350
332
 
351
- // rc.11.1: autosteer-resolution telemetry. Fires when the JSONL
352
- // tail confirms which path the autosteer resolved through:
353
- // - via:'fold' TUI absorbed the paste as queued_command
354
- // attachment inside the current turn (one combined reply).
355
- // - via:'new-turn' — TUI dequeued as a fresh user turn
356
- // (extra-turn-reply pathway).
357
- // Querying `kind='autosteer-resolution'` in the events DB gives
358
- // a complete audit trail of every autosteer's fate.
359
- onAutosteerResolution: (sessionKey, payload /* , entry */) => {
333
+ // 0.13 D3: the cycle settled end any autonomous visuals.
334
+ onIdle: (sessionKey /* , entry */) => {
335
+ try { sessionFeedback?.endCycle(sessionKey); }
336
+ catch (err) { logger.error?.(`[${botName}] onIdle failed: ${err.message}`); }
337
+ },
338
+
339
+ onQuestionResumed: (sessionKey, entry) => {
360
340
  try {
361
- logEvent('autosteer-resolution', {
362
- chat_id: getChatIdFromKey(sessionKey),
363
- session_key: sessionKey,
364
- msg_id: payload?.msgId,
365
- via: payload?.via,
366
- backend: payload?.backend || 'tmux',
367
- });
341
+ const ctx = entry?.pendingQueue?.[0]?.context;
342
+ // 0.13 D1 (S8): the answer landed — claude is working again. Resume
343
+ // the per-turn typing loop that onQuestionAsked paused. Fires before
344
+ // the reactor re-arm and independently of it (typing must come back
345
+ // even if this turn carries no reactor).
346
+ ctx?.typing?.resume?.();
347
+ const r = ctx?.reactor;
348
+ if (r && typeof r.setState === 'function') {
349
+ r.setState('THINKING');
350
+ logEvent('question-resumed', { chat_id: getChatIdFromKey(sessionKey), session_key: sessionKey });
351
+ }
368
352
  } catch (err) {
369
- logger.error?.(`[${botName}] autosteer-resolution handler: ${err.message}`);
353
+ logger.error?.(`[${botName}] onQuestionResumed failed: ${err.message}`);
370
354
  }
371
355
  },
372
356
 
373
- // rc.11.1: autosteer-match-miss telemetry. Fires when JSONL has
374
- // a queue-folded or top-level user-message that LOOKS LIKE an
375
- // autosteer dequeue but no pending content matched. This is the
376
- // signature of a content-encoding mismatch (the exact rc.11.1
377
- // bug — oneLine ' / ' vs newline form). The payload includes
378
- // head-snippets of both sides for diff-by-eye in the events DB.
379
- onAutosteerMatchMiss: (sessionKey, payload /* , entry */) => {
357
+ onQuestionAsked: async (sessionKey, payload, entry) => {
380
358
  try {
381
- logEvent('autosteer-match-miss', {
382
- chat_id: getChatIdFromKey(sessionKey),
383
- session_key: sessionKey,
384
- phase: payload?.phase,
385
- text_head: payload?.text_head ?? payload?.prompt_head,
386
- pending_head: payload?.pending_head,
387
- pending_count: payload?.pending_count,
388
- backend: payload?.backend || 'tmux',
389
- });
359
+ // 0.13 D1 (S8): waiting-on-user — pause the per-turn typing loop the
360
+ // moment the keyboard goes up. "typing…" while the bot waits on the
361
+ // USER is the inverted signal; D1 keeps the turn (and its typing
362
+ // loop) alive through the whole wait, so without this pause every
363
+ // ask-wait would show continuous typing. Guarded no-op on dead turns.
364
+ try { entry?.pendingQueue?.[0]?.context?.typing?.pause?.(); } catch { /* guarded */ }
365
+ if (typeof renderQuestion !== 'function') return;
366
+ await renderQuestion({ sessionKey, ...payload });
390
367
  } catch (err) {
391
- logger.error?.(`[${botName}] autosteer-match-miss handler: ${err.message}`);
368
+ logger.error?.(`[${botName}] onQuestionAsked failed: ${err.message}`);
369
+ }
370
+ },
371
+
372
+ onBgWorkStatus: async (sessionKey, payload) => {
373
+ try {
374
+ if (!bot) return;
375
+ const chatId = getChatIdFromKey(sessionKey);
376
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
377
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
378
+ const state = payload?.state;
379
+ if (state === 'running') {
380
+ if (bgStatusMsgIds.has(sessionKey)) return; // already showing one
381
+ const res = await tg(bot, 'sendMessage', {
382
+ chat_id: chatId,
383
+ text: '⏳ Working in the background — I\'ll keep an eye on it and report when it\'s done.',
384
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
385
+ }, { source: 'bg-work-status', botName });
386
+ const mid = res?.message_id ?? res?.result?.message_id ?? null;
387
+ if (mid != null) bgStatusMsgIds.set(sessionKey, mid);
388
+ logEvent('bg-work-status', {
389
+ chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
390
+ state: 'running', message_id: mid,
391
+ });
392
+ } else if (state === 'cleared') {
393
+ const mid = bgStatusMsgIds.get(sessionKey);
394
+ bgStatusMsgIds.delete(sessionKey);
395
+ if (mid == null) return;
396
+ await tg(bot, 'editMessageText', {
397
+ chat_id: chatId, message_id: mid, text: '✅ Background work finished.',
398
+ }, { source: 'bg-work-status', botName }).catch(() => {});
399
+ logEvent('bg-work-status', {
400
+ chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
401
+ state: 'cleared', message_id: mid,
402
+ });
403
+ }
404
+ } catch (err) {
405
+ logger.error?.(`[${botName}] bg-work-status handler: ${err.message}`);
392
406
  }
393
407
  },
394
408
 
@@ -571,6 +585,42 @@ function createSdkCallbacks({
571
585
  }
572
586
  },
573
587
 
588
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
589
+ // 'compaction-warn' when context crosses the chat's threshold at turn-end
590
+ // (proactive) or claude is auto-compacting now (reactive). Post a chat
591
+ // message proposing /compact so the user can compact on their terms BEFORE
592
+ // an auto-compaction interrupts a turn (and detaches the channels bridge).
593
+ // Opt-in per chat (lib/compaction-warn.js) — CliProcess only emits when
594
+ // enabled, so no extra config gate is needed here. Best-effort send.
595
+ onCompactionWarn: (sessionKey, payload /* , entry */) => {
596
+ try {
597
+ const chatId = getChatIdFromKey(sessionKey);
598
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
599
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
600
+ const kind = payload?.kind === 'reactive' ? 'reactive' : 'proactive';
601
+ logEvent('compaction-warn', {
602
+ chat_id: chatId,
603
+ session_key: sessionKey,
604
+ kind,
605
+ pct: payload?.pct ?? null,
606
+ backend: payload?.backend ?? 'cli',
607
+ });
608
+ if (!bot) return;
609
+ const text = kind === 'reactive'
610
+ ? '🗜️ Auto-compacting now — context filled up. If this turn goes quiet, please resend. (Tip: running `/compact` at a natural break avoids mid-task compactions.)'
611
+ : `📚 Heads up — this chat's context is ~${payload?.pct ?? '?'}% full. To avoid an auto-compaction that can interrupt a turn, run \`/compact\` (optionally with a hint, e.g. \`/compact keep the recent decisions\`) at a natural break — or \`/new\` for a fresh start.`;
612
+ tg(bot, 'sendMessage', {
613
+ chat_id: chatId,
614
+ text,
615
+ ...(threadId ? { message_thread_id: threadId } : {}),
616
+ }, { source: 'compaction-warn', botName }).catch((err) => {
617
+ logger.error?.(`[${botName}] compaction-warn send failed: ${err.message}`);
618
+ });
619
+ } catch (err) {
620
+ logger.error?.(`[${botName}] compaction-warn handler: ${err.message}`);
621
+ }
622
+ },
623
+
574
624
  // 0.10.0 rc.42 #8: tmux backend hook-tail error observability.
575
625
  // Persistent failures of the hook ndjson tail degrade H3 idle-
576
626
  // ceiling accuracy and H4 Stop-synth coverage with no surface
@@ -752,58 +802,6 @@ function createSdkCallbacks({
752
802
  }
753
803
  },
754
804
 
755
- // rc.7: tmux backend autosteer NEW-TURN extra reply. Fires when
756
- // the TUI's queue dequeued an autosteered paste as a fresh user
757
- // turn — typically when the primary turn was a short / cached
758
- // reply that finished before the paste could fold in. The
759
- // payload carries { msgId, text, sessionId, backend }; msgId is
760
- // the Telegram message_id of the autosteered user message
761
- // (so the reply lands as a Telegram reply to that message,
762
- // matching how Ivan visually expects autosteer to behave).
763
- //
764
- // SDK backend NEVER emits this — its PostToolBatch fold path
765
- // guarantees one combined reply via the primary pm.send().
766
- // This is purely a tmux-backend bridge.
767
- onExtraTurnReply: (sessionKey, payload /* , entry */) => {
768
- try {
769
- const text = payload?.text;
770
- const msgId = payload?.msgId;
771
- // rc.9: ALWAYS tear down extra-turn visuals first, even if
772
- // text/msgId are missing — otherwise the typing-indicator
773
- // loop would run forever for that session.
774
- stopExtraTurnVisuals(sessionKey, msgId);
775
- if (!text || msgId == null) return;
776
- const chatId = getChatIdFromKey(sessionKey);
777
- const threadIdRaw = getThreadIdFromKey(sessionKey);
778
- const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
779
- if (!bot) {
780
- logger.error?.(`[${botName}] extra-turn-reply: bot not ready, dropping ${text.length} chars`);
781
- return;
782
- }
783
- const params = {
784
- chat_id: chatId,
785
- text,
786
- reply_to_message_id: msgId,
787
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
788
- };
789
- // Don't await — keep the pm event loop unblocked.
790
- tg(bot, 'sendMessage', params,
791
- { source: 'extra-turn-reply', botName }).catch((err) => {
792
- logger.error?.(`[${botName}] extra-turn-reply send failed: ${err.message}`);
793
- });
794
- logEvent('extra-turn-reply', {
795
- chat_id: chatId,
796
- session_key: sessionKey,
797
- thread_id: threadIdRaw,
798
- msg_id: msgId,
799
- text_len: text.length,
800
- backend: payload?.backend || 'tmux',
801
- });
802
- } catch (err) {
803
- logger.error?.(`[${botName}] extra-turn-reply handler: ${err.message}`);
804
- }
805
- },
806
-
807
805
  // SDK auto-compaction observability. Fires when SDK emits
808
806
  // SDKCompactBoundaryMessage. Surfaces a quiet system status note
809
807
  // to the chat so the user knows the bot is busy reorganising
@@ -106,10 +106,39 @@ function getTopicConfig(chatConfig, threadId) {
106
106
  return overrides;
107
107
  }
108
108
 
109
+ /**
110
+ * Resolve the config object a per-chat/topic setting (model/effort) should be
111
+ * WRITTEN to, given where the command/card was used. When in a topic, return
112
+ * (creating if needed) that topic's override entry — so a /model in the Music
113
+ * topic targets Music alone and matches what the per-topic card displays,
114
+ * instead of leaking to the chat root and every other topic that inherits it
115
+ * (the 2026-06-12 "/model in Music does nothing" bug). At the chat level
116
+ * (no thread), the writable scope is the chat config itself.
117
+ *
118
+ * @returns {{ scope: object, threadId: (string|null) }}
119
+ */
120
+ function getConfigWriteScope(chatConfig, threadId) {
121
+ const tid = (threadId == null || threadId === '') ? null : String(threadId);
122
+ // Mirror getSessionKey's isolation rule: a per-topic override only takes
123
+ // effect when isolateTopics === true (otherwise every topic shares the
124
+ // chatId-keyed session and topics[tid].model is silently ignored — the
125
+ // 2026-06-12 review found the topic-scope fix re-broke /model on the DEFAULT
126
+ // non-isolated chats and made the card lie). So write the topic scope ONLY
127
+ // when isolated; otherwise write the chat root (the session that actually
128
+ // runs), and report threadId:null so the audit row reflects chat-level reach.
129
+ if (tid && chatConfig?.isolateTopics === true) {
130
+ chatConfig.topics = chatConfig.topics || {};
131
+ chatConfig.topics[tid] = chatConfig.topics[tid] || {};
132
+ return { scope: chatConfig.topics[tid], threadId: tid };
133
+ }
134
+ return { scope: chatConfig, threadId: null };
135
+ }
136
+
109
137
  module.exports = {
110
138
  getSessionKey,
111
139
  getChatIdFromKey,
112
140
  getThreadIdFromKey,
113
141
  getTopicName,
114
142
  getTopicConfig,
143
+ getConfigWriteScope,
115
144
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * album-reactions — apply one status reaction to every message of a Telegram
3
+ * album (the anchor + its siblings), so a multi-file send shows the same emoji
4
+ * on each item instead of only the first.
5
+ *
6
+ * Background: Telegram delivers an album as N separate messages sharing a
7
+ * media_group_id; polygram coalesces them into ONE turn anchored on the first.
8
+ * The status reactor therefore only ever reacted to that anchor, leaving the
9
+ * sibling files with no visible reaction (the rc.16 observation). This mirrors
10
+ * the reactor's emoji onto the siblings.
11
+ *
12
+ * Semantics:
13
+ * - The ANCHOR (first id) is awaited so a failure surfaces to the reactor's
14
+ * own error handling (same as the single-message path).
15
+ * - SIBLINGS are best-effort: a failure on one must not drop the anchor's
16
+ * reaction or the other siblings (and must not throw — reactions are
17
+ * cosmetic). They also can't share the anchor's fate of being retried.
18
+ * - Calls are sequential to respect Telegram's setMessageReaction rate limit
19
+ * (~5/s/chat) — an album is ≤10 items so this stays well within budget.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ /**
25
+ * @param {object} opts
26
+ * @param {Function} opts.tg async (bot, method, params, meta) => any
27
+ * @param {*} opts.bot
28
+ * @param {string} opts.chatId
29
+ * @param {number[]} opts.msgIds [anchor, ...siblings] — anchor first
30
+ * @param {string|null} opts.emoji emoji to set, or null/'' to clear
31
+ * @param {string} [opts.botName]
32
+ */
33
+ async function applyReactionToMessages({ tg, bot, chatId, msgIds, emoji, botName } = {}) {
34
+ const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
35
+ const ids = Array.isArray(msgIds) ? msgIds : [];
36
+ for (let i = 0; i < ids.length; i++) {
37
+ const params = { chat_id: chatId, message_id: ids[i], reaction };
38
+ const meta = {
39
+ source: i === 0 ? 'status-reaction' : 'status-reaction-album-sibling',
40
+ botName,
41
+ };
42
+ if (i === 0) {
43
+ await tg(bot, 'setMessageReaction', params, meta); // anchor: surface failure
44
+ } else {
45
+ await tg(bot, 'setMessageReaction', params, meta).catch(() => {}); // siblings: best-effort
46
+ }
47
+ }
48
+ }
49
+
50
+ module.exports = { applyReactionToMessages };
@@ -94,8 +94,15 @@ function parseResponse(text, { stickerMap = {}, emojiToSticker = {} } = {}) {
94
94
  }
95
95
 
96
96
  // Solo-emoji shortcuts (single emoji → sticker if mapped, else reaction).
97
- const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed)
98
- || /^\p{Emoji}️?$/u.test(trimmed);
97
+ // Keycap-base guard (2026-06-10 "2+2 → 4" dropped reply): Unicode \p{Emoji}
98
+ // includes 0-9/#/* (keycap bases), so a bare single-digit answer parsed as
99
+ // a reaction with text:'' and the channels dispatcher dropped it. A solo
100
+ // digit/hash/asterisk is always TEXT; real keycap emoji (4️⃣) are
101
+ // multi-codepoint and never hit this branch anyway. The optional ️
102
+ // also catches a stray variation selector on a digit ("4️") — same class.
103
+ const emojiOnly = !/^[0-9#*]️?$/.test(trimmed)
104
+ && (/^\p{Emoji_Presentation}$/u.test(trimmed)
105
+ || /^\p{Emoji}️?$/u.test(trimmed));
99
106
 
100
107
  if (emojiOnly && trimmed) {
101
108
  if (emojiToSticker[trimmed]) {
@@ -78,9 +78,13 @@ function startTyping({
78
78
  const opts = threadId ? { message_thread_id: threadId } : {};
79
79
  let timer = null;
80
80
  let stopped = false;
81
+ // 0.13 D1 (S8): paused while the bot is waiting on the USER (an open `ask`).
82
+ // "typing…" is exactly the wrong signal at the one moment user action is
83
+ // required — and prod questions run 16+ min (≈600 misleading pings each).
84
+ let paused = false;
81
85
 
82
86
  const tick = async () => {
83
- if (stopped) return;
87
+ if (stopped || paused) return;
84
88
  const s = getState(key);
85
89
  if (s.suspendedUntil > Date.now()) return;
86
90
  try {
@@ -120,11 +124,22 @@ function startTyping({
120
124
  timer = setInterval(tick, intervalMs);
121
125
  timer.unref?.();
122
126
 
123
- return () => {
127
+ const stop = () => {
124
128
  stopped = true;
125
129
  if (timer) clearInterval(timer);
126
130
  timer = null;
127
131
  };
132
+ // 0.13 D1: question lifecycle controls. pause() silences ticks without
133
+ // tearing the loop down; resume() restarts immediately (the answer landed,
134
+ // claude is working again). Attached to the stop function so every existing
135
+ // `stopTyping()` call site keeps working unchanged.
136
+ stop.pause = () => { paused = true; };
137
+ stop.resume = () => {
138
+ if (stopped) return;
139
+ paused = false;
140
+ tick();
141
+ };
142
+ return stop;
128
143
  }
129
144
 
130
145
  function resetChatTypingState(chatId) {
@@ -60,6 +60,10 @@ const DEFAULT_SETTLE_MS = 500;
60
60
  * @param {number} [opts.pollMs=300]
61
61
  * @param {number} [opts.settleMs=500]
62
62
  * @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
63
+ * @param {Function} [opts.onTrigger] — (name) => void, called AT FIRE
64
+ * TIME (not gate resolution). Telemetry hung off the success-path return
65
+ * misses the matched-then-died sequence (2026-06-10 prod: gate matched
66
+ * session-age, then TMUX_SESSION_GONE). Errors are swallowed.
63
67
  * @param {object} [opts.logger=console]
64
68
  * @param {string} [opts.label='startup-gate']
65
69
  * @returns {Promise<{matchedTriggers: string[], elapsedMs: number}>}
@@ -74,6 +78,7 @@ async function runStartupGate({
74
78
  pollMs = DEFAULT_POLL_MS,
75
79
  settleMs = DEFAULT_SETTLE_MS,
76
80
  timeoutCode = 'TUI_STARTUP_TIMEOUT',
81
+ onTrigger = null,
77
82
  logger = console,
78
83
  label = 'startup-gate',
79
84
  } = {}) {
@@ -97,21 +102,28 @@ async function runStartupGate({
97
102
  // 30-second `can't find pane` spam with no diagnostic about WHY.
98
103
  let lastPane = null;
99
104
  // Progress-aware gate: timestamp of the last observed pane CHANGE (or
100
- // trigger send). Seeded to startedAt so a pane that's frozen from the
101
- // very first capture still trips stallMs. Only consulted when
102
- // stallEnabled.
105
+ // trigger send). Only consulted when stallEnabled.
103
106
  let lastActivityAt = startedAt;
107
+ // Music incident (2026-06-01): the stall timer must NOT arm while the pane
108
+ // is still BLANK. A blank-and-unchanging pane means claude hasn't started
109
+ // rendering yet (slow cold-start), NOT that it wedged — the TUI for some
110
+ // topics takes 30-45s to first-render. Arming the stall timer on a blank
111
+ // pane killed a legitimate slow spawn at stallMs with a false "wedged".
112
+ // So the stall clock only runs once the pane has shown non-whitespace
113
+ // content; before that, only the absolute `deadlineMs` governs.
114
+ let sawContent = false;
104
115
 
105
116
  while (Date.now() < deadline) {
106
- // Stall check (progress-aware): the pane has been doing nothing for
107
- // stallMs. Distinct from the absolute deadline fires early so a
108
- // wedged TUI fails fast, while an actively-progressing one (download
109
- // bar, dialog navigation) keeps resetting lastActivityAt below.
110
- if (stallEnabled && Date.now() - lastActivityAt >= stallMs) {
117
+ // Stall check (progress-aware): the pane RENDERED something and has then
118
+ // been static for stallMs genuinely wedged. Gated on sawContent so a
119
+ // blank cold-start isn't mistaken for a wedge. Fires early so a truly
120
+ // hung TUI fails fast, while an actively-progressing one (download bar,
121
+ // dialog navigation) keeps resetting lastActivityAt below.
122
+ if (stallEnabled && sawContent && Date.now() - lastActivityAt >= stallMs) {
111
123
  const err = new Error(
112
- `[${label}] startup gate saw no pane activity for ${stallMs}ms for ${tmuxName} ` +
124
+ `[${label}] startup gate: pane rendered then went static for ${stallMs}ms for ${tmuxName} ` +
113
125
  `(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
114
- `Pane appears wedged. Last pane content:\n` +
126
+ `Appears wedged. Last pane content:\n` +
115
127
  _formatPaneTail(lastPane),
116
128
  );
117
129
  err.code = timeoutCode;
@@ -147,6 +159,15 @@ async function runStartupGate({
147
159
  await new Promise(r => setTimeout(r, settleMs));
148
160
  continue;
149
161
  }
162
+ // First non-whitespace content = the TUI has started rendering. Only
163
+ // from here does the stall timer become meaningful (before this, a blank
164
+ // pane is cold-start, governed by the absolute deadline). Seed
165
+ // lastActivityAt at the moment content first appears so the stall window
166
+ // is measured from "rendered", not from spawn.
167
+ if (!sawContent && pane && pane.trim().length > 0) {
168
+ sawContent = true;
169
+ lastActivityAt = Date.now();
170
+ }
150
171
  // Progress signal: any change in pane content is activity → reset the
151
172
  // stall clock. A captureWide that returns the SAME bytes is NOT
152
173
  // activity (a frozen download bar at 24% reads identically each poll).
@@ -158,13 +179,22 @@ async function runStartupGate({
158
179
  for (const trigger of triggers) {
159
180
  if (seen.has(trigger.name)) continue;
160
181
  if (!trigger.regex.test(pane)) continue;
161
- try {
162
- await runner.sendControl(tmuxName, trigger.key);
163
- } catch (err) {
164
- logger.warn?.(`[${label}] sendControl(${trigger.key}) failed for trigger=${trigger.name}: ${err.message}`);
182
+ // `keys: [...]` sends a sequence (dialog navigation — e.g. Down,Enter
183
+ // to pick a non-default option); `key:` remains the single-key form.
184
+ // Sequence keys go as separate send-keys calls with a short delay —
185
+ // Ink dialogs can swallow the second key of a same-batch sequence.
186
+ const keySeq = Array.isArray(trigger.keys) ? trigger.keys : [trigger.key];
187
+ for (let ki = 0; ki < keySeq.length; ki++) {
188
+ if (ki > 0) await new Promise(r => setTimeout(r, Math.min(settleMs, 120)));
189
+ try {
190
+ await runner.sendControl(tmuxName, keySeq[ki]);
191
+ } catch (err) {
192
+ logger.warn?.(`[${label}] sendControl(${keySeq[ki]}) failed for trigger=${trigger.name}: ${err.message}`);
193
+ }
165
194
  }
166
195
  seen.add(trigger.name);
167
196
  matchedTriggers.push(trigger.name);
197
+ try { onTrigger?.(trigger.name); } catch { /* telemetry must not break the gate */ }
168
198
  matched = true;
169
199
  // Sending a key is activity — navigating the TUI counts as progress
170
200
  // even if the pre-transition pane text was static (e.g. a dialog we