polygram 0.12.0-rc.33 → 0.12.0-rc.34
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/lib/handlers/config-ui.js +17 -2
- package/lib/handlers/edit-redelivery.js +117 -0
- package/lib/handlers/slash-commands.js +26 -24
- package/lib/process/cli-process.js +52 -4
- package/lib/process-manager.js +22 -1
- package/lib/questions/questions.js +7 -3
- package/lib/sdk/callbacks.js +18 -0
- package/package.json +1 -1
- package/polygram.js +25 -8
|
@@ -78,9 +78,24 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
|
|
|
78
78
|
const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
|
|
79
79
|
const ver = MODEL_VERSIONS_DESC[model] || model;
|
|
80
80
|
const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
|
|
81
|
+
// Running vs configured: cli can't hot-swap model/effort, so a /model or
|
|
82
|
+
// /effort change is PENDING until the session reloads (on the next message).
|
|
83
|
+
// Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
|
|
84
|
+
// vs the configured one — so the card never claims a model the session
|
|
85
|
+
// isn't actually running (the "says opus, runs sonnet" confusion). SDK
|
|
86
|
+
// applies live (its proc value tracks config) so no drift line ever shows.
|
|
87
|
+
const proc = alive ? pm.get(sessionKey) : null;
|
|
88
|
+
const runModel = proc && proc.model;
|
|
89
|
+
const runEffort = proc && proc.effort;
|
|
90
|
+
const modelLine = (runModel && runModel !== model)
|
|
91
|
+
? `Model: ${runModel} (running) → ${model} (pending — applies on your next message)`
|
|
92
|
+
: `Model: ${model} (${ver})`;
|
|
93
|
+
const effortLine = (runEffort && runEffort !== effort)
|
|
94
|
+
? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
|
|
95
|
+
: `Effort: ${effort}`;
|
|
81
96
|
const head =
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
`${modelLine}\n` +
|
|
98
|
+
`${effortLine}\n` +
|
|
84
99
|
`Agent: ${agent}\n` +
|
|
85
100
|
`Process: ${alive ? 'warm' : 'cold'}\n` +
|
|
86
101
|
`Session: ${sess}`;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-turn edit re-delivery (0.12.0). When the user edits a Telegram message AFTER claude's turn
|
|
5
|
+
* has finished, re-dispatch the edited message as a NEW turn so claude acts on the change — the
|
|
6
|
+
* "an edit is just a message" model. The mid-turn case (turn still in flight) stays with the
|
|
7
|
+
* existing injector (lib/handlers/edit-correction.js); this is the post-turn path.
|
|
8
|
+
*
|
|
9
|
+
* Spec: docs/0.12.0-edit-redelivery-spec.md (twice-reviewed). Key correctness points the review
|
|
10
|
+
* surfaced:
|
|
11
|
+
* - Convey the change via reply_to carrying the OLD text (the caller captures it before
|
|
12
|
+
* recordInbound overwrites the row) — replying to the live row would quote the NEW text, so
|
|
13
|
+
* claude would see no before/after and couldn't tell it's an edit.
|
|
14
|
+
* - GATE on the REAL edited message, NOT the synthetic: a self-reply_to trips shouldHandle's
|
|
15
|
+
* `repliesToOtherUser` and drops paired users in mention-gated groups.
|
|
16
|
+
* - The synthetic is `_isReplay`-tagged → no new editable row, never replay-eligible, error
|
|
17
|
+
* reply suppressed.
|
|
18
|
+
* - A re-edit while our re-run is in flight FOLDS via inject (the interlock) rather than
|
|
19
|
+
* starting a second turn.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {object} deps.pm ProcessManager (get(sessionKey).inFlight, injectUserMessage)
|
|
23
|
+
* @param {object} deps.config
|
|
24
|
+
* @param {Function} deps.getSessionKey (chatId, threadId, chatConfig) => sessionKey
|
|
25
|
+
* @param {Function} deps.shouldHandle (msg, chatConfig, botUsername) => boolean — the real gate
|
|
26
|
+
* @param {Function} deps.dispatchHandleMessage (sessionKey, chatId, msg, bot) => void
|
|
27
|
+
* @param {object} deps.bot
|
|
28
|
+
* @param {RegExp|null} [deps.mentionRe] strips the @bot mention from the new text for the body
|
|
29
|
+
* @param {string} deps.botUsername
|
|
30
|
+
* @param {Function} [deps.react] (chatId, msgId) => void|Promise — on-edit acknowledgment
|
|
31
|
+
* @param {Function} [deps.logEvent]
|
|
32
|
+
* @param {object} [deps.logger]
|
|
33
|
+
* @returns {(editedMsg: object, oldText: string|null) => boolean} true when a fresh turn was dispatched
|
|
34
|
+
*/
|
|
35
|
+
function createEditRedelivery({
|
|
36
|
+
pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
|
|
37
|
+
mentionRe = null, botUsername, react, logEvent = () => {}, logger = console,
|
|
38
|
+
} = {}) {
|
|
39
|
+
return function maybePostTurnEdit(editedMsg, oldText) {
|
|
40
|
+
try {
|
|
41
|
+
if (!editedMsg?.chat) return false;
|
|
42
|
+
const chatId = editedMsg.chat.id.toString();
|
|
43
|
+
const chatConfig = config.chats[chatId];
|
|
44
|
+
if (!chatConfig) return false;
|
|
45
|
+
|
|
46
|
+
// Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
|
|
47
|
+
const optOut = chatConfig.editCorrection != null
|
|
48
|
+
? chatConfig.editCorrection === false
|
|
49
|
+
: config.bot?.editCorrection === false;
|
|
50
|
+
if (optOut) return false;
|
|
51
|
+
|
|
52
|
+
const newText = editedMsg.text || editedMsg.caption || '';
|
|
53
|
+
if (!newText) return false; // blanked / media-only → nothing to act on
|
|
54
|
+
// Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
|
|
55
|
+
// caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
|
|
56
|
+
if (oldText != null && oldText === newText) return false;
|
|
57
|
+
|
|
58
|
+
const threadId = editedMsg.message_thread_id?.toString() || null;
|
|
59
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
60
|
+
|
|
61
|
+
// Interlock: a turn is in flight (most likely OUR own re-run, whose _isReplay row reads
|
|
62
|
+
// not-live so the mid-turn injector skipped it and fell through to here). Fold the re-edit
|
|
63
|
+
// via inject instead of spawning a SECOND re-dispatch turn for the same message.
|
|
64
|
+
const proc = pm?.get?.(sessionKey);
|
|
65
|
+
if (proc?.inFlight) {
|
|
66
|
+
pm.injectUserMessage?.(sessionKey, {
|
|
67
|
+
content: `[edit] I edited my message again — it now reads: ${newText}`,
|
|
68
|
+
priority: 'next',
|
|
69
|
+
msgId: editedMsg.message_id,
|
|
70
|
+
});
|
|
71
|
+
logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
|
|
76
|
+
// NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
|
|
77
|
+
// paired user editing an un-mentioned message in a mention-gated group.
|
|
78
|
+
if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
|
|
79
|
+
|
|
80
|
+
// Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
|
|
81
|
+
// claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
|
|
82
|
+
try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
|
|
83
|
+
|
|
84
|
+
// Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
|
|
85
|
+
// reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
|
|
86
|
+
// renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
|
|
87
|
+
const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
|
|
88
|
+
const synthetic = {
|
|
89
|
+
chat: editedMsg.chat,
|
|
90
|
+
message_id: editedMsg.message_id,
|
|
91
|
+
from: editedMsg.from,
|
|
92
|
+
text: cleanNew,
|
|
93
|
+
date: editedMsg.date,
|
|
94
|
+
...(threadId && { message_thread_id: Number(threadId) }),
|
|
95
|
+
reply_to_message: {
|
|
96
|
+
message_id: editedMsg.message_id,
|
|
97
|
+
from: editedMsg.from,
|
|
98
|
+
text: oldText || '',
|
|
99
|
+
date: editedMsg.date,
|
|
100
|
+
},
|
|
101
|
+
_isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
|
|
102
|
+
};
|
|
103
|
+
dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
|
|
104
|
+
logEvent('edit-redelivered', {
|
|
105
|
+
chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
|
|
106
|
+
old_len: (oldText || '').length, new_len: newText.length,
|
|
107
|
+
});
|
|
108
|
+
return true;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Never throw out of the edited_message handler.
|
|
111
|
+
logger.error?.(`[edit-redelivery] ${e.message}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { createEditRedelivery };
|
|
@@ -186,6 +186,30 @@ function createSlashCommands({
|
|
|
186
186
|
return { anyActive: !applied };
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
// cli can't hot-swap model/effort live (they are spawn-time --model /
|
|
190
|
+
// --effort flags). The change is persisted to chatConfig and applies when
|
|
191
|
+
// the session next (re)spawns — getOrSpawn's reload-on-drift makes that the
|
|
192
|
+
// user's NEXT message, conversation preserved (--resume). So give an honest
|
|
193
|
+
// suffix per backend instead of the misleading "I'll switch when I finish".
|
|
194
|
+
// (Pre-fix this checked backendName === 'channels', but 0.12.0 renamed the
|
|
195
|
+
// cli backend 'channels' → 'cli', so it never fired and every cli user got
|
|
196
|
+
// the wrong message — Review F#10 regression.)
|
|
197
|
+
const cliAwareSuffix = (anyActive) => {
|
|
198
|
+
const liveBackend = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
|
|
199
|
+
if (liveBackend === 'cli') {
|
|
200
|
+
const proc = typeof pm.get === 'function' ? pm.get(sessionKey) : null;
|
|
201
|
+
return proc && proc.inFlight
|
|
202
|
+
? ' — applies after this turn (conversation kept)'
|
|
203
|
+
: ' — applies on your next message (conversation kept)';
|
|
204
|
+
}
|
|
205
|
+
// cli but cold (no live proc): the next message cold-spawns with the new flag.
|
|
206
|
+
if (!liveBackend && (chatConfig.pm || config.bot?.pm) === 'cli') {
|
|
207
|
+
return ' — applies on your next message';
|
|
208
|
+
}
|
|
209
|
+
// SDK: applied live (anyActive false) or no live session to push into.
|
|
210
|
+
return anyActive ? ' — I\'ll switch when I finish' : '';
|
|
211
|
+
};
|
|
212
|
+
|
|
189
213
|
// /model X
|
|
190
214
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
191
215
|
const newModel = text.slice(7).trim();
|
|
@@ -199,18 +223,7 @@ function createSlashCommands({
|
|
|
199
223
|
}), 'log model change');
|
|
200
224
|
const { anyActive } = await applyConfigChange('model', newModel);
|
|
201
225
|
const ver = (modelVersionsDesc && modelVersionsDesc[newModel]) || newModel;
|
|
202
|
-
|
|
203
|
-
// live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
|
|
204
|
-
// pm.setModel returns false → `anyActive` is true → user saw the
|
|
205
|
-
// misleading "I'll switch when I finish" message. Now we detect
|
|
206
|
-
// the channels backend explicitly and give an honest answer:
|
|
207
|
-
// settings are persisted to chatConfig and take effect on the next
|
|
208
|
-
// /reset or /new (channels lacks an in-place re-init path).
|
|
209
|
-
const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
|
|
210
|
-
const suffix = backendName === 'channels'
|
|
211
|
-
? ` — applies on next /reset (channels)`
|
|
212
|
-
: (anyActive ? ` — I'll switch when I finish` : '');
|
|
213
|
-
await sendReply(`Model → ${newModel} (${ver})${suffix}`);
|
|
226
|
+
await sendReply(`Model → ${newModel} (${ver})${cliAwareSuffix(anyActive)}`);
|
|
214
227
|
} else {
|
|
215
228
|
await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
|
|
216
229
|
}
|
|
@@ -229,18 +242,7 @@ function createSlashCommands({
|
|
|
229
242
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
230
243
|
}), 'log effort change');
|
|
231
244
|
const { anyActive } = await applyConfigChange('effort', newEffort);
|
|
232
|
-
|
|
233
|
-
// live — its setModel/applyFlagSettings throw UNSUPPORTED_OPERATION,
|
|
234
|
-
// pm.setModel returns false → `anyActive` is true → user saw the
|
|
235
|
-
// misleading "I'll switch when I finish" message. Now we detect
|
|
236
|
-
// the channels backend explicitly and give an honest answer:
|
|
237
|
-
// settings are persisted to chatConfig and take effect on the next
|
|
238
|
-
// /reset or /new (channels lacks an in-place re-init path).
|
|
239
|
-
const backendName = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
|
|
240
|
-
const suffix = backendName === 'channels'
|
|
241
|
-
? ` — applies on next /reset (channels)`
|
|
242
|
-
: (anyActive ? ` — I'll switch when I finish` : '');
|
|
243
|
-
await sendReply(`Effort → ${newEffort}${suffix}`);
|
|
245
|
+
await sendReply(`Effort → ${newEffort}${cliAwareSuffix(anyActive)}`);
|
|
244
246
|
} else {
|
|
245
247
|
await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
|
|
246
248
|
}
|
|
@@ -545,9 +545,15 @@ class CliProcess extends Process {
|
|
|
545
545
|
// after this.
|
|
546
546
|
const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
|
|
547
547
|
const agent = topicConfig?.agent || opts.chatConfig?.agent || opts.agent;
|
|
548
|
-
const model =
|
|
549
|
-
const effort =
|
|
548
|
+
const model = this._resolveModel(opts);
|
|
549
|
+
const effort = this._resolveEffort(opts);
|
|
550
550
|
const resolvedCwd = topicConfig?.cwd || opts.chatConfig?.cwd || opts.cwd;
|
|
551
|
+
// Record the spawn-time model/effort. cli has no live model/effort swap
|
|
552
|
+
// (they are spawn-time --model / --effort flags), so getOrSpawn detects a
|
|
553
|
+
// /model or /effort drift against these and reloads — --resume preserves
|
|
554
|
+
// the conversation, the new flag takes effect. See wouldReloadFor.
|
|
555
|
+
this.model = model;
|
|
556
|
+
this.effort = effort;
|
|
551
557
|
|
|
552
558
|
// File-send outbound cap (bot → user). Backend-derived (cloud 50MB vs
|
|
553
559
|
// local Bot API server 2GB via opts.localApi) with per-topic/chat
|
|
@@ -1744,6 +1750,38 @@ class CliProcess extends Process {
|
|
|
1744
1750
|
return this._bgWorkSince !== null;
|
|
1745
1751
|
}
|
|
1746
1752
|
|
|
1753
|
+
/**
|
|
1754
|
+
* Resolve the model / effort for a spawn context using the topic→chat→
|
|
1755
|
+
* fallback precedence (mirrors the spawn path). Single source of truth shared
|
|
1756
|
+
* by start() (which records this.model / this.effort) and wouldReloadFor()
|
|
1757
|
+
* (which compares the current config to those spawn-time values).
|
|
1758
|
+
*/
|
|
1759
|
+
_resolveModel(opts) {
|
|
1760
|
+
const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
|
|
1761
|
+
return topicConfig?.model || opts.chatConfig?.model || opts.model;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
_resolveEffort(opts) {
|
|
1765
|
+
const topicConfig = opts.threadId && opts.chatConfig?.topics?.[opts.threadId];
|
|
1766
|
+
return topicConfig?.effort || opts.chatConfig?.effort || opts.effort;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* getOrSpawn calls this before reusing a warm proc. cli can't hot-swap model
|
|
1771
|
+
* or effort (spawn-time flags), so when the resolved config has drifted from
|
|
1772
|
+
* what we spawned with AND we are idle, the proc must be killed + cold-
|
|
1773
|
+
* respawned (--resume keeps the conversation; the new --model / --effort takes
|
|
1774
|
+
* effect). In-flight → false: fold the message into the running turn; the
|
|
1775
|
+
* drift reloads on the next idle dispatch. SDK procs apply model live and do
|
|
1776
|
+
* NOT implement this method, so process-manager only reloads when it exists.
|
|
1777
|
+
* @returns {boolean}
|
|
1778
|
+
*/
|
|
1779
|
+
wouldReloadFor(spawnContext) {
|
|
1780
|
+
if (this.inFlight || this.closed) return false;
|
|
1781
|
+
return this._resolveModel(spawnContext) !== this.model
|
|
1782
|
+
|| this._resolveEffort(spawnContext) !== this.effort;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1747
1785
|
/**
|
|
1748
1786
|
* Stall-watchdog for detached background work (0.12.0 background-work
|
|
1749
1787
|
* lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
|
|
@@ -2629,8 +2667,18 @@ class CliProcess extends Process {
|
|
|
2629
2667
|
*/
|
|
2630
2668
|
writeQuestionAnswer(toolCallId, result) {
|
|
2631
2669
|
this._openQuestions.delete(toolCallId);
|
|
2632
|
-
|
|
2633
|
-
|
|
2670
|
+
const noneLeft = this._openQuestions.size === 0;
|
|
2671
|
+
if (noneLeft) this._stopQuestionKeepAlive();
|
|
2672
|
+
const wrote = this._writeToBridge({ kind: 'question_answer', tool_call_id: toolCallId, result: result ?? {} });
|
|
2673
|
+
// Re-light progress: claude is about to resume working on the answer. The per-turn reactor
|
|
2674
|
+
// cleared when claude posted its reply + asked, and no tool hooks fired during the wait, so
|
|
2675
|
+
// it stayed cleared — the post-answer work was invisible ("why don't I see it working after
|
|
2676
|
+
// submit?", hire topic 2026-06-09). On a REAL answer (cancelled/timeout END the turn → let
|
|
2677
|
+
// the normal teardown clear), signal polygram to re-arm the turn's working reaction.
|
|
2678
|
+
if (noneLeft && result && !result.cancelled && !result.timedout) {
|
|
2679
|
+
this.emit('question-resumed');
|
|
2680
|
+
}
|
|
2681
|
+
return wrote;
|
|
2634
2682
|
}
|
|
2635
2683
|
|
|
2636
2684
|
_startQuestionKeepAlive() {
|
package/lib/process-manager.js
CHANGED
|
@@ -58,6 +58,11 @@ const CALLBACK_TO_EVENT = {
|
|
|
58
58
|
// the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
|
|
59
59
|
// the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
|
|
60
60
|
onQuestionAsked: 'question-asked',
|
|
61
|
+
// 0.12.0 question-progress-resume: CliProcess emits 'question-resumed' (no payload) when a
|
|
62
|
+
// blocking `ask` resolves with a real answer and the turn resumes working. The callback
|
|
63
|
+
// re-arms the per-turn reactor (it cleared during the wait, no hooks re-lit it). See
|
|
64
|
+
// docs/0.12.0-question-resume-progress-spec.md.
|
|
65
|
+
onQuestionResumed: 'question-resumed',
|
|
61
66
|
onQueueDrop: 'queue-drop',
|
|
62
67
|
onThinking: 'thinking',
|
|
63
68
|
// Tmux backend: TUI shows in-pane approval prompt. SDK backend
|
|
@@ -237,7 +242,23 @@ class ProcessManager {
|
|
|
237
242
|
// caller receives a proc whose start() has fully resolved.
|
|
238
243
|
const pendingStart = this._starting.get(sessionKey);
|
|
239
244
|
if (pendingStart) await pendingStart;
|
|
240
|
-
|
|
245
|
+
// Reload-on-drift (cli): a warm cli proc can't hot-swap model/effort
|
|
246
|
+
// (spawn-time flags). If the resolved config has drifted and the proc is
|
|
247
|
+
// idle, kill it (preserves session_id) and fall through to a cold respawn
|
|
248
|
+
// → --resume keeps the conversation, the new --model/--effort takes
|
|
249
|
+
// effect. In-flight cli procs and SDK procs (no wouldReloadFor — they
|
|
250
|
+
// apply model live) are reused unchanged.
|
|
251
|
+
if (typeof existing.wouldReloadFor === 'function' && existing.wouldReloadFor(spawnContext)) {
|
|
252
|
+
this._logEvent('cli-config-reload', {
|
|
253
|
+
sessionKey,
|
|
254
|
+
from_model: existing.model,
|
|
255
|
+
from_effort: existing.effort,
|
|
256
|
+
});
|
|
257
|
+
await this.kill(sessionKey, 'config-reload');
|
|
258
|
+
// fall through to the cold-spawn path below — respawns with --resume
|
|
259
|
+
} else {
|
|
260
|
+
return existing;
|
|
261
|
+
}
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
// Provisional new-process cost — ask the factory but don't start yet.
|
|
@@ -62,14 +62,18 @@ function renderCurrent(state, callbackBase) {
|
|
|
62
62
|
lines.push(String(q.question ?? ''));
|
|
63
63
|
lines.push('');
|
|
64
64
|
opts.forEach((o, i) => {
|
|
65
|
-
|
|
65
|
+
// Multi-select renders as a checklist (☐ unchecked / ☑️ checked) so it's legible as a
|
|
66
|
+
// tap-to-toggle-then-Submit control, NOT a single-select button. Single-select keeps `•`.
|
|
67
|
+
const mark = multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : '• ';
|
|
66
68
|
lines.push(`${mark}${truncLabel(o.label)}${o.description ? ` — ${o.description}` : ''}`);
|
|
67
69
|
if (o.preview) lines.push(String(o.preview).slice(0, MAX_PREVIEW));
|
|
68
70
|
});
|
|
69
|
-
if (multi) lines.push('\nTap
|
|
71
|
+
if (multi) lines.push('\nTap to check/uncheck — pick one or more, then Submit.');
|
|
70
72
|
|
|
71
73
|
const rows = opts.map((o, i) => ([{
|
|
72
|
-
|
|
74
|
+
// The checkbox glyph on the BUTTON is the load-bearing affordance: an unchecked option
|
|
75
|
+
// must show ☐ (not a bare label) or it reads like a single-select tap-to-submit button.
|
|
76
|
+
text: `${multi ? (state.toggles[i] ? '☑️ ' : '☐ ') : ''}${truncLabel(o.label)}`,
|
|
73
77
|
callback_data: `${callbackBase}:opt:${i}`,
|
|
74
78
|
}]));
|
|
75
79
|
if (multi) {
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -352,6 +352,24 @@ function createSdkCallbacks({
|
|
|
352
352
|
// Telegram inline keyboard via the question handler (late-bound from polygram).
|
|
353
353
|
// payload: {chatId, threadId, turnId, toolCallId, questions}. The handler
|
|
354
354
|
// itself is anti-hang (answers claude {cancelled} on any send failure).
|
|
355
|
+
// 0.12 interactive questions: the blocking `ask` resolved → the turn is resuming work. The
|
|
356
|
+
// per-turn reactor cleared when claude posted its reply + asked, and no hooks fired during
|
|
357
|
+
// the wait, so it never came back — the post-answer work showed no progress ("why don't I
|
|
358
|
+
// see it working after submit?"). Re-arm the head pending's reactor to THINKING. setState is
|
|
359
|
+
// a safe no-op if the reactor was stopped; typing is unaffected (its per-turn loop runs to
|
|
360
|
+
// turn-end). Guarded — never throws on a torn-down turn.
|
|
361
|
+
onQuestionResumed: (sessionKey, entry) => {
|
|
362
|
+
try {
|
|
363
|
+
const r = entry?.pendingQueue?.[0]?.context?.reactor;
|
|
364
|
+
if (r && typeof r.setState === 'function') {
|
|
365
|
+
r.setState('THINKING');
|
|
366
|
+
logEvent('question-resumed', { chat_id: getChatIdFromKey(sessionKey), session_key: sessionKey });
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
logger.error?.(`[${botName}] onQuestionResumed failed: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
355
373
|
onQuestionAsked: async (sessionKey, payload) => {
|
|
356
374
|
try {
|
|
357
375
|
if (typeof renderQuestion !== 'function') return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.34",
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -66,6 +66,7 @@ const { createHandleConfigCallback } = require('./lib/handlers/config-callback')
|
|
|
66
66
|
const { createHandleAbort } = require('./lib/handlers/abort');
|
|
67
67
|
const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
|
|
68
68
|
const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
|
|
69
|
+
const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
|
|
69
70
|
const { createSlashCommands } = require('./lib/handlers/slash-commands');
|
|
70
71
|
const { createApprovals } = require('./lib/handlers/approvals');
|
|
71
72
|
const { canonicalizeToolInput } = require('./lib/canonical-json');
|
|
@@ -643,6 +644,7 @@ let handleAbortIfRequested = null;
|
|
|
643
644
|
let autosteer = null;
|
|
644
645
|
let dispatchSlashCommand = null;
|
|
645
646
|
let maybeInjectEditCorrection = null;
|
|
647
|
+
let maybePostTurnEdit = null;
|
|
646
648
|
|
|
647
649
|
// rc.20: approvalCardText + safeParse moved to lib/approvals/ui.js.
|
|
648
650
|
// 0.9.0 commit 29: makeCanUseTool / handleApprovalCallback /
|
|
@@ -2053,6 +2055,10 @@ function createBot(token) {
|
|
|
2053
2055
|
}
|
|
2054
2056
|
const chatId = ctx.editedMessage.chat.id.toString();
|
|
2055
2057
|
if (!knownChat(chatId)) return;
|
|
2058
|
+
// 0.12.0 spec §3 (HARD): read the OLD text BEFORE recordInbound overwrites the row — the
|
|
2059
|
+
// post-turn changed-guard compares it, and the re-dispatch quotes it in reply_to so claude sees
|
|
2060
|
+
// the before/after. Reading after recordInbound would yield the new text (useless).
|
|
2061
|
+
const oldText = db.getMessage(chatId, ctx.editedMessage.message_id)?.text ?? null;
|
|
2056
2062
|
recordInbound(ctx.editedMessage);
|
|
2057
2063
|
logEvent('message-edited', {
|
|
2058
2064
|
chat_id: chatId,
|
|
@@ -2061,16 +2067,15 @@ function createBot(token) {
|
|
|
2061
2067
|
});
|
|
2062
2068
|
console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
|
|
2063
2069
|
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2066
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
// mid-turn without /stop + resend. No-op when the turn already
|
|
2069
|
-
// completed.
|
|
2070
|
+
// Mid-turn (turn still in flight) → fold into the running turn via the 0.9.0 injector. Post-turn
|
|
2071
|
+
// (idle) — OR the injector no-ops because the turn just settled at the boundary — → re-dispatch
|
|
2072
|
+
// the edited message as a NEW turn (0.12.0 edit re-delivery). `injected===false` gives the
|
|
2073
|
+
// self-contained boundary fall-through.
|
|
2070
2074
|
try {
|
|
2071
|
-
maybeInjectEditCorrection?.(ctx.editedMessage);
|
|
2075
|
+
const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
|
|
2076
|
+
if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText);
|
|
2072
2077
|
} catch (err) {
|
|
2073
|
-
console.error(`[${BOT_NAME}] edit
|
|
2078
|
+
console.error(`[${BOT_NAME}] edit handler error: ${err.message}`);
|
|
2074
2079
|
}
|
|
2075
2080
|
});
|
|
2076
2081
|
|
|
@@ -2505,6 +2510,18 @@ async function main() {
|
|
|
2505
2510
|
getIsShuttingDown: () => isShuttingDown,
|
|
2506
2511
|
logger: console,
|
|
2507
2512
|
}));
|
|
2513
|
+
// 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
|
|
2514
|
+
// An edit while a turn is in flight folds via maybeInjectEditCorrection; an edit after the turn
|
|
2515
|
+
// (or when the injector no-ops at the boundary) re-dispatches as a new turn. The on-edit 👀 is a
|
|
2516
|
+
// pre-turn ack for the cold-spawn gap; the synthetic turn's own reactor then takes over the msg.
|
|
2517
|
+
maybePostTurnEdit = createEditRedelivery({
|
|
2518
|
+
pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
|
|
2519
|
+
mentionRe, botUsername,
|
|
2520
|
+
react: (chatId, msgId) => applyReactionToMessages({
|
|
2521
|
+
tg, bot, chatId, msgIds: [msgId], emoji: '👀', botName: BOT_NAME,
|
|
2522
|
+
}).catch(() => {}),
|
|
2523
|
+
logEvent, logger: console,
|
|
2524
|
+
});
|
|
2508
2525
|
({ pollBot, startPollWatchdog } = createPollLoop({
|
|
2509
2526
|
db, dbWrite, config, botName: BOT_NAME,
|
|
2510
2527
|
isWellFormedMessage, getTopicName,
|