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.
- package/config.example.json +4 -3
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1277 -70
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +224 -78
package/lib/sdk/callbacks.js
CHANGED
|
@@ -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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
const
|
|
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
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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}]
|
|
329
|
+
logger.error?.(`[${botName}] onTurnStart failed: ${err.message}`);
|
|
348
330
|
}
|
|
349
331
|
},
|
|
350
332
|
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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}]
|
|
353
|
+
logger.error?.(`[${botName}] onQuestionResumed failed: ${err.message}`);
|
|
370
354
|
}
|
|
371
355
|
},
|
|
372
356
|
|
|
373
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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}]
|
|
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
|
package/lib/session-key.js
CHANGED
|
@@ -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 };
|
package/lib/telegram/parse.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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]) {
|
package/lib/telegram/typing.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/tmux/startup-gate.js
CHANGED
|
@@ -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).
|
|
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
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
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
|
|
124
|
+
`[${label}] startup gate: pane rendered then went static for ${stallMs}ms for ${tmuxName} ` +
|
|
113
125
|
`(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
|
|
114
|
-
`
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|