polygram 0.12.0-rc.9 → 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 +3 -1
- 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
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
'use strict';
|
|
29
29
|
|
|
30
|
+
const { getConfigWriteScope } = require('../session-key');
|
|
31
|
+
|
|
30
32
|
function createSlashCommands({
|
|
31
33
|
config,
|
|
32
34
|
db,
|
|
@@ -40,6 +42,7 @@ function createSlashCommands({
|
|
|
40
42
|
getOrSpawnForChat,
|
|
41
43
|
parsePairCodeArgs,
|
|
42
44
|
modelVersionsDesc,
|
|
45
|
+
saveConfig = () => {},
|
|
43
46
|
botName,
|
|
44
47
|
logEvent,
|
|
45
48
|
logger = console,
|
|
@@ -186,31 +189,49 @@ function createSlashCommands({
|
|
|
186
189
|
return { anyActive: !applied };
|
|
187
190
|
};
|
|
188
191
|
|
|
192
|
+
// cli can't hot-swap model/effort live (they are spawn-time --model /
|
|
193
|
+
// --effort flags). The change is persisted to chatConfig and applies when
|
|
194
|
+
// the session next (re)spawns — getOrSpawn's reload-on-drift makes that the
|
|
195
|
+
// user's NEXT message, conversation preserved (--resume). So give an honest
|
|
196
|
+
// suffix per backend instead of the misleading "I'll switch when I finish".
|
|
197
|
+
// (Pre-fix this checked backendName === 'channels', but 0.12.0 renamed the
|
|
198
|
+
// cli backend 'channels' → 'cli', so it never fired and every cli user got
|
|
199
|
+
// the wrong message — Review F#10 regression.)
|
|
200
|
+
const cliAwareSuffix = (anyActive) => {
|
|
201
|
+
const liveBackend = typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null;
|
|
202
|
+
if (liveBackend === 'cli') {
|
|
203
|
+
const proc = typeof pm.get === 'function' ? pm.get(sessionKey) : null;
|
|
204
|
+
return proc && proc.inFlight
|
|
205
|
+
? ' — applies after this turn (conversation kept)'
|
|
206
|
+
: ' — applies on your next message (conversation kept)';
|
|
207
|
+
}
|
|
208
|
+
// cli but cold (no live proc): the next message cold-spawns with the new flag.
|
|
209
|
+
if (!liveBackend && (chatConfig.pm || config.bot?.pm) === 'cli') {
|
|
210
|
+
return ' — applies on your next message';
|
|
211
|
+
}
|
|
212
|
+
// SDK: applied live (anyActive false) or no live session to push into.
|
|
213
|
+
return anyActive ? ' — I\'ll switch when I finish' : '';
|
|
214
|
+
};
|
|
215
|
+
|
|
189
216
|
// /model X
|
|
190
217
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
191
218
|
const newModel = text.slice(7).trim();
|
|
192
219
|
if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
// Write to the topic when in one (so Music ≠ General) and persist to
|
|
221
|
+
// config.json so it survives restarts — both were missing (2026-06-12).
|
|
222
|
+
const { scope: wScope, threadId: wThread } = getConfigWriteScope(chatConfig, threadIdStr);
|
|
223
|
+
const oldModel = wScope.model != null ? wScope.model : chatConfig.model;
|
|
224
|
+
wScope.model = newModel;
|
|
225
|
+
try { saveConfig(); }
|
|
226
|
+
catch (err) { logger.error?.(`[${botName}] /model saveConfig failed: ${err.message}`); }
|
|
195
227
|
dbWrite(() => db.logConfigChange({
|
|
196
|
-
chat_id: chatId, thread_id:
|
|
228
|
+
chat_id: chatId, thread_id: wThread, field: 'model',
|
|
197
229
|
old_value: oldModel, new_value: newModel,
|
|
198
230
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
199
231
|
}), 'log model change');
|
|
200
232
|
const { anyActive } = await applyConfigChange('model', newModel);
|
|
201
233
|
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}`);
|
|
234
|
+
await sendReply(`Model → ${newModel} (${ver})${cliAwareSuffix(anyActive)}`);
|
|
214
235
|
} else {
|
|
215
236
|
await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
|
|
216
237
|
}
|
|
@@ -221,26 +242,18 @@ function createSlashCommands({
|
|
|
221
242
|
if (botAllowsCommands && text.startsWith('/effort ')) {
|
|
222
243
|
const newEffort = text.slice(8).trim();
|
|
223
244
|
if (['low', 'medium', 'high', 'xhigh', 'max'].includes(newEffort)) {
|
|
224
|
-
const
|
|
225
|
-
|
|
245
|
+
const { scope: wScope, threadId: wThread } = getConfigWriteScope(chatConfig, threadIdStr);
|
|
246
|
+
const oldEffort = wScope.effort != null ? wScope.effort : chatConfig.effort;
|
|
247
|
+
wScope.effort = newEffort;
|
|
248
|
+
try { saveConfig(); }
|
|
249
|
+
catch (err) { logger.error?.(`[${botName}] /effort saveConfig failed: ${err.message}`); }
|
|
226
250
|
dbWrite(() => db.logConfigChange({
|
|
227
|
-
chat_id: chatId, thread_id:
|
|
251
|
+
chat_id: chatId, thread_id: wThread, field: 'effort',
|
|
228
252
|
old_value: oldEffort, new_value: newEffort,
|
|
229
253
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
230
254
|
}), 'log effort change');
|
|
231
255
|
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}`);
|
|
256
|
+
await sendReply(`Effort → ${newEffort}${cliAwareSuffix(anyActive)}`);
|
|
244
257
|
} else {
|
|
245
258
|
await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
|
|
246
259
|
}
|
package/lib/history-preload.js
CHANGED
|
@@ -69,6 +69,7 @@ function makeSessionStartHook({
|
|
|
69
69
|
db,
|
|
70
70
|
chatId,
|
|
71
71
|
threadId = null,
|
|
72
|
+
isolateTopics = false,
|
|
72
73
|
allowedChatIds = null,
|
|
73
74
|
limit = DEFAULT_PRELOAD_LIMIT,
|
|
74
75
|
since = DEFAULT_PRELOAD_SINCE,
|
|
@@ -93,6 +94,7 @@ function makeSessionStartHook({
|
|
|
93
94
|
rows = history.recent(db, {
|
|
94
95
|
chatId: String(chatId),
|
|
95
96
|
threadId: threadId ?? null,
|
|
97
|
+
scopeThread: isolateTopics === true, // cross-topic bleed fix (null = General only)
|
|
96
98
|
limit,
|
|
97
99
|
since,
|
|
98
100
|
includeOutbound: true,
|
|
@@ -170,6 +172,7 @@ function buildHistoryBlock({
|
|
|
170
172
|
db,
|
|
171
173
|
chatId,
|
|
172
174
|
threadId = null,
|
|
175
|
+
isolateTopics = false,
|
|
173
176
|
excludeMsgId = null,
|
|
174
177
|
limit = DEFAULT_PRELOAD_LIMIT,
|
|
175
178
|
since = DEFAULT_PRELOAD_SINCE,
|
|
@@ -181,6 +184,9 @@ function buildHistoryBlock({
|
|
|
181
184
|
rows = history.recent(db, {
|
|
182
185
|
chatId: String(chatId),
|
|
183
186
|
threadId: threadId ?? null,
|
|
187
|
+
// isolateTopics → scope to THIS thread (null = General only); else the
|
|
188
|
+
// chat-wide session legitimately spans all topics. (cross-topic bleed fix)
|
|
189
|
+
scopeThread: isolateTopics === true,
|
|
184
190
|
limit,
|
|
185
191
|
since,
|
|
186
192
|
includeOutbound: true,
|
package/lib/history.js
CHANGED
|
@@ -56,11 +56,17 @@ function fts5Escape(query) {
|
|
|
56
56
|
.join(' ');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function recent(db, { chatId, threadId = null, limit = 20, since = null, includeOutbound = true, allowedChatIds = null } = {}) {
|
|
59
|
+
function recent(db, { chatId, threadId = null, scopeThread = false, limit = 20, since = null, includeOutbound = true, allowedChatIds = null } = {}) {
|
|
60
60
|
const clamped = clampLimit(limit);
|
|
61
61
|
let sql = 'SELECT * FROM messages WHERE chat_id = ?';
|
|
62
62
|
const params = [String(chatId)];
|
|
63
63
|
if (threadId) { sql += ' AND thread_id = ?'; params.push(String(threadId)); }
|
|
64
|
+
// scopeThread: a per-topic (isolateTopics) session must see ONLY its own thread —
|
|
65
|
+
// and a null thread means the General topic, i.e. thread_id IS NULL, NOT "all
|
|
66
|
+
// threads". Without this the General topic preloads the whole chat and bleeds
|
|
67
|
+
// other topics' context (2026-06-09 cross-topic incident). The history-skill CLI
|
|
68
|
+
// keeps the old "null threadId = all threads" default (scopeThread=false).
|
|
69
|
+
else if (scopeThread) { sql += ' AND thread_id IS NULL'; }
|
|
64
70
|
if (!includeOutbound) sql += ` AND direction = 'in'`;
|
|
65
71
|
const sinceMs = parseSinceMs(since);
|
|
66
72
|
if (sinceMs) { sql += ' AND ts >= ?'; params.push(Date.now() - sinceMs); }
|
package/lib/model-costs.js
CHANGED
|
@@ -28,6 +28,10 @@ const MODEL_COSTS = {
|
|
|
28
28
|
'claude-haiku-4-5': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreation: 1 },
|
|
29
29
|
// Claude Opus 4.7 (1M context)
|
|
30
30
|
'claude-opus-4-7': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
|
|
31
|
+
// Claude Opus 4.8 — what `--model opus` resolves to now (verified in the
|
|
32
|
+
// Music-topic transcript 2026-06-10). Same Opus pricing; without this entry
|
|
33
|
+
// opus turns fall back to the Sonnet `default` and undercount cost ~5×.
|
|
34
|
+
'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
|
|
31
35
|
// Default fallback — Sonnet rates (safest mid-tier estimate).
|
|
32
36
|
default: { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
33
37
|
};
|
|
@@ -50,7 +50,12 @@ const ToolCallMessageSchema = z.object({
|
|
|
50
50
|
kind: z.literal('tool'),
|
|
51
51
|
session: NonEmptyString,
|
|
52
52
|
tool_call_id: ToolCallId,
|
|
53
|
-
|
|
53
|
+
// 'ask' (0.12 interactive questions): a blocking tool whose answer rides back
|
|
54
|
+
// on a `question_answer` daemon→bridge message (NOT the fast `tool_ack`); its
|
|
55
|
+
// args are {chat_id, turn_id?, questions:[...]}, not reply-shaped. _dispatchToolCall
|
|
56
|
+
// branches on the name so the reply-only paths (chat_id-mismatch, content-dedup,
|
|
57
|
+
// reply-turn-binding) don't run for it.
|
|
58
|
+
name: z.enum(['reply', 'react', 'edit_message', 'ask']),
|
|
54
59
|
args: z.object({}).passthrough(),
|
|
55
60
|
}).passthrough();
|
|
56
61
|
|
|
@@ -116,6 +121,20 @@ const ToolAckMessageSchema = z.object({
|
|
|
116
121
|
tool_call_id: ToolCallId,
|
|
117
122
|
ok: z.boolean(),
|
|
118
123
|
error: z.string().optional(),
|
|
124
|
+
// 0.13: the delivered Telegram message_id, surfaced back to claude so it can
|
|
125
|
+
// `edit_message` that bubble for progressive status. Present on a successful
|
|
126
|
+
// `reply`/`edit_message` ack; absent on errors / re-acks.
|
|
127
|
+
message_id: z.union([z.number(), z.string()]).nullish(),
|
|
128
|
+
}).passthrough();
|
|
129
|
+
|
|
130
|
+
// 0.12 interactive questions: carries the user's answer back for an `ask` tool
|
|
131
|
+
// call. Separate from `tool_ack` (which has no payload field and resolves the
|
|
132
|
+
// fast reply round-trip) so a blocking question can return a structured result.
|
|
133
|
+
// `result` is one of {answers:[...]} | {cancelled:true} | {timedout:true}.
|
|
134
|
+
const QuestionAnswerMessageSchema = z.object({
|
|
135
|
+
kind: z.literal('question_answer'),
|
|
136
|
+
tool_call_id: ToolCallId,
|
|
137
|
+
result: z.object({}).passthrough(),
|
|
119
138
|
}).passthrough();
|
|
120
139
|
|
|
121
140
|
const PingMessageSchema = z.object({
|
|
@@ -128,6 +147,7 @@ const AnyDaemonToBridgeMessage = z.discriminatedUnion('kind', [
|
|
|
128
147
|
UserMessageSchema,
|
|
129
148
|
PermVerdictMessageSchema,
|
|
130
149
|
ToolAckMessageSchema,
|
|
150
|
+
QuestionAnswerMessageSchema,
|
|
131
151
|
PingMessageSchema,
|
|
132
152
|
]);
|
|
133
153
|
|
|
@@ -170,6 +190,7 @@ module.exports = {
|
|
|
170
190
|
UserMessageSchema,
|
|
171
191
|
PermVerdictMessageSchema,
|
|
172
192
|
ToolAckMessageSchema,
|
|
193
|
+
QuestionAnswerMessageSchema,
|
|
173
194
|
PingMessageSchema,
|
|
174
195
|
AnyDaemonToBridgeMessage,
|
|
175
196
|
// helpers
|
|
@@ -108,12 +108,40 @@ function awaitToolAck(toolCallId) {
|
|
|
108
108
|
})
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function resolveToolAck(toolCallId, ok, error) {
|
|
111
|
+
function resolveToolAck(toolCallId, ok, error, messageId) {
|
|
112
112
|
const p = pendingToolCalls.get(toolCallId)
|
|
113
113
|
if (!p) return
|
|
114
114
|
pendingToolCalls.delete(toolCallId)
|
|
115
115
|
clearTimeout(p.timer)
|
|
116
|
-
|
|
116
|
+
// 0.13: resolve with the delivered message_id so the CallTool handler can hand
|
|
117
|
+
// it back to claude (for edit_message). null when the daemon didn't carry one.
|
|
118
|
+
ok ? p.resolve({ message_id: messageId ?? null }) : p.reject(new Error(error || 'daemon rejected delivery'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
|
|
122
|
+
// Separate from tool_ack: a question can take MINUTES (the daemon-side 30-min
|
|
123
|
+
// question timeout — aligned to the turn absolute cap — resolves it with {timedout}
|
|
124
|
+
// before this hard ceiling, which sits just above as the last-resort backstop).
|
|
125
|
+
const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
|
|
126
|
+
const QUESTION_ANSWER_TIMEOUT_MS = 32 * 60 * 1000
|
|
127
|
+
|
|
128
|
+
function awaitQuestionAnswer(toolCallId) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
pendingQuestions.delete(toolCallId)
|
|
132
|
+
resolve({ timedout: true }) // never reject — the agent gets a clean result
|
|
133
|
+
}, QUESTION_ANSWER_TIMEOUT_MS)
|
|
134
|
+
timer.unref?.() // a pending answer must not, by itself, hold the event loop open
|
|
135
|
+
pendingQuestions.set(toolCallId, { resolve, timer })
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveQuestionAnswer(toolCallId, result) {
|
|
140
|
+
const p = pendingQuestions.get(toolCallId)
|
|
141
|
+
if (!p) return
|
|
142
|
+
pendingQuestions.delete(toolCallId)
|
|
143
|
+
clearTimeout(p.timer)
|
|
144
|
+
p.resolve(result ?? { cancelled: true })
|
|
117
145
|
}
|
|
118
146
|
|
|
119
147
|
// ─── Socket: connect, handshake, then bidirectional JSON-lines ──
|
|
@@ -210,7 +238,11 @@ function handleDaemonMessage(msg) {
|
|
|
210
238
|
break
|
|
211
239
|
|
|
212
240
|
case 'tool_ack':
|
|
213
|
-
resolveToolAck(msg.tool_call_id, msg.ok, msg.error)
|
|
241
|
+
resolveToolAck(msg.tool_call_id, msg.ok, msg.error, msg.message_id)
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
case 'question_answer':
|
|
245
|
+
resolveQuestionAnswer(msg.tool_call_id, msg.result)
|
|
214
246
|
break
|
|
215
247
|
|
|
216
248
|
case 'ping':
|
|
@@ -272,7 +304,12 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
272
304
|
description: 'Send a message back to the originating Telegram chat. ' +
|
|
273
305
|
'chat_id MUST match the chat_id from the inbound <channel> tag. ' +
|
|
274
306
|
'turn_id MUST echo the turn_id from the inbound <channel> tag (when present) ' +
|
|
275
|
-
'so concurrent turns route their replies correctly.'
|
|
307
|
+
'so concurrent turns route their replies correctly. ' +
|
|
308
|
+
'ALWAYS set consumed_turn_ids to the turn_id of EVERY <channel> message this ' +
|
|
309
|
+
'reply answers or absorbs (including mid-turn follow-ups) — it is how polygram ' +
|
|
310
|
+
'confirms delivery of follow-ups. ' +
|
|
311
|
+
'Returns {ok, message_id}: keep the message_id to update that bubble in place ' +
|
|
312
|
+
'with `edit_message` (progressive status on long tasks).',
|
|
276
313
|
inputSchema: {
|
|
277
314
|
type: 'object',
|
|
278
315
|
properties: {
|
|
@@ -280,18 +317,99 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
280
317
|
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta (required for correct turn routing).' },
|
|
281
318
|
text: { type: 'string', description: 'Message body (markdown ok).' },
|
|
282
319
|
files: { type: 'array', items: { type: 'string' }, description: 'Optional absolute file paths to attach.' },
|
|
320
|
+
// 0.13 D2 Tier 2C: the fold-acknowledgment contract. The single turn_id
|
|
321
|
+
// field can't express a combined reply that covers a mid-turn follow-up
|
|
322
|
+
// (P0 spike Q-B: claude echoes only the trigger id) — this array can.
|
|
323
|
+
consumed_turn_ids: {
|
|
324
|
+
type: 'array', items: { type: 'string' },
|
|
325
|
+
description: 'turn_id of EVERY <channel> message this reply answers or has absorbed since your last reply (including mid-turn follow-ups). Always set it.',
|
|
326
|
+
},
|
|
283
327
|
},
|
|
284
328
|
required: ['chat_id', 'text'],
|
|
285
329
|
},
|
|
330
|
+
}, {
|
|
331
|
+
// 0.13: edit a message already sent via `reply`, in place — the progressive-
|
|
332
|
+
// status primitive. Update one bubble instead of sending several.
|
|
333
|
+
name: 'edit_message',
|
|
334
|
+
description: 'Edit a message you previously sent via `reply`, in place. Use this for ' +
|
|
335
|
+
'progressive status on a long task: send a short status with `reply`, take the ' +
|
|
336
|
+
'returned message_id, then `edit_message` it as you make progress (ending with ' +
|
|
337
|
+
'the final answer). Keep status in PLAIN LANGUAGE — never tool names like Bash/Edit. ' +
|
|
338
|
+
'One bubble only (no chunking); for long content use `reply` instead.',
|
|
339
|
+
inputSchema: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
343
|
+
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
|
|
344
|
+
message_id: { type: 'number', description: 'The message_id returned by the `reply` tool call you want to update.' },
|
|
345
|
+
text: { type: 'string', description: 'New full message body (markdown ok) — replaces the old text.' },
|
|
346
|
+
},
|
|
347
|
+
required: ['chat_id', 'message_id', 'text'],
|
|
348
|
+
},
|
|
349
|
+
}, {
|
|
350
|
+
// 0.12 interactive questions: ask the Telegram user a multiple-choice question
|
|
351
|
+
// as tap-to-answer inline buttons. USE THIS instead of any interactive menu —
|
|
352
|
+
// it returns the user's selection(s) as the tool result. Blocks until answered.
|
|
353
|
+
name: 'ask',
|
|
354
|
+
description: 'Ask the Telegram user a multiple-choice question (rendered as inline ' +
|
|
355
|
+
'keyboard buttons; supports multiSelect + a free-text "Other"). Use this ' +
|
|
356
|
+
'for ANY choice/confirmation — never present a numbered list and wait, and ' +
|
|
357
|
+
'never use a terminal selection menu. Blocks until the user answers; returns ' +
|
|
358
|
+
'{answers:[{header,selected:[label...],other?}]} (or {cancelled}/{timedout}).',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
|
|
363
|
+
turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
|
|
364
|
+
questions: {
|
|
365
|
+
type: 'array', description: 'Up to 4 questions, asked one at a time.',
|
|
366
|
+
items: {
|
|
367
|
+
type: 'object',
|
|
368
|
+
properties: {
|
|
369
|
+
header: { type: 'string', description: 'Short chip label (≤12 chars).' },
|
|
370
|
+
question: { type: 'string', description: 'The question text.' },
|
|
371
|
+
multiSelect: { type: 'boolean', description: 'Allow selecting several options (checkboxes).' },
|
|
372
|
+
allowOther: { type: 'boolean', description: 'Offer a free-text "type my own" answer (default true).' },
|
|
373
|
+
options: {
|
|
374
|
+
type: 'array', description: '2–4 options.',
|
|
375
|
+
items: { type: 'object', properties: {
|
|
376
|
+
label: { type: 'string', description: 'Button label (≤40 chars).' },
|
|
377
|
+
description: { type: 'string', description: 'Shown in the message body.' },
|
|
378
|
+
} },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
required: ['question', 'options'],
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
required: ['chat_id', 'questions'],
|
|
386
|
+
},
|
|
286
387
|
}],
|
|
287
388
|
}
|
|
288
389
|
})
|
|
289
390
|
|
|
290
391
|
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
291
|
-
if (req.params.name !== 'reply') {
|
|
392
|
+
if (req.params.name !== 'reply' && req.params.name !== 'ask' && req.params.name !== 'edit_message') {
|
|
292
393
|
return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
|
|
293
394
|
}
|
|
294
395
|
const toolCallId = randomUUID()
|
|
396
|
+
|
|
397
|
+
// `ask` blocks for the user's answer (question_answer), NOT a fast tool_ack.
|
|
398
|
+
if (req.params.name === 'ask') {
|
|
399
|
+
const answerP = awaitQuestionAnswer(toolCallId)
|
|
400
|
+
try {
|
|
401
|
+
sock.write(JSON.stringify({
|
|
402
|
+
kind: 'tool', session: SESSION_KEY, tool_call_id: toolCallId, name: 'ask', args: req.params.arguments,
|
|
403
|
+
}) + '\n')
|
|
404
|
+
} catch (e) {
|
|
405
|
+
// The daemon never received the ask → no row, no sweep. Resolve the awaiter
|
|
406
|
+
// now (clears the 20-min timer) instead of stranding the agent on it.
|
|
407
|
+
resolveQuestionAnswer(toolCallId, { cancelled: true, error: `bridge write failed: ${e?.message || e}` })
|
|
408
|
+
}
|
|
409
|
+
const result = await answerP
|
|
410
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
|
|
411
|
+
}
|
|
412
|
+
|
|
295
413
|
const ackP = awaitToolAck(toolCallId)
|
|
296
414
|
sock.write(JSON.stringify({
|
|
297
415
|
kind: 'tool',
|
|
@@ -301,8 +419,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
|
301
419
|
args: req.params.arguments,
|
|
302
420
|
}) + '\n')
|
|
303
421
|
try {
|
|
304
|
-
await ackP
|
|
305
|
-
|
|
422
|
+
const ack = await ackP
|
|
423
|
+
// Return {ok, message_id} as JSON so claude can read the delivered bubble's
|
|
424
|
+
// id and `edit_message` it later (progressive status). For a plain reply with
|
|
425
|
+
// no id (solo sticker/reaction) message_id is null.
|
|
426
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: ack?.message_id ?? null }) }] }
|
|
306
427
|
} catch (err) {
|
|
307
428
|
return { content: [{ type: 'text', text: `delivery failed: ${err.message}` }], isError: true }
|
|
308
429
|
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* - lib/telegram/deliver.js for the actual sendMessage loop
|
|
10
10
|
* - bot.api.sendPhoto/Document for file attachments
|
|
11
11
|
*
|
|
12
|
-
* The dispatcher returns `{ok: boolean, error?: string}` —
|
|
13
|
-
* relays this to the bridge as tool_ack, which surfaces to Claude as
|
|
14
|
-
* tool's return value (`
|
|
12
|
+
* The dispatcher returns `{ok: boolean, error?: string, message_id?: number}` —
|
|
13
|
+
* CliProcess relays this to the bridge as tool_ack, which surfaces to Claude as
|
|
14
|
+
* the tool's return value (`{ok:true, message_id}` on success so claude can
|
|
15
|
+
* `edit_message` that bubble; error message on failure).
|
|
15
16
|
*
|
|
16
17
|
* Decoupled from polygram.js: factory takes {bot, send, chunkText, logger}
|
|
17
18
|
* — same shape SDK callbacks already use — so it can be tested with fakes
|
|
@@ -37,6 +38,13 @@ const os = require('node:os');
|
|
|
37
38
|
// additional roots (e.g. a configured uploads dir).
|
|
38
39
|
const DEFAULT_ATTACHMENT_BASE = path.join(os.tmpdir(), 'polygram-attachments');
|
|
39
40
|
|
|
41
|
+
// Review 2026-06-12 hardening: cap files[] per reply so one (possibly
|
|
42
|
+
// prompt-injected) call can't fan out unlimited uploads past the per-call
|
|
43
|
+
// rate limit; cap the per-session owned-message-id set so the edit-ownership
|
|
44
|
+
// tracker can't grow unbounded on a long-lived session.
|
|
45
|
+
const MAX_FILES_PER_REPLY = 10;
|
|
46
|
+
const OWNED_MSG_CAP = 256;
|
|
47
|
+
|
|
40
48
|
function isPathUnder(child, parent) {
|
|
41
49
|
const rel = path.relative(parent, child);
|
|
42
50
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
@@ -124,12 +132,69 @@ function createChannelsToolDispatcher({
|
|
|
124
132
|
const deliverAgent = processAndDeliverAgentText
|
|
125
133
|
|| require('../telegram/process-agent-reply').processAndDeliverAgentText;
|
|
126
134
|
|
|
135
|
+
// Per-session set of message_ids THIS session created (via reply/edit).
|
|
136
|
+
// edit_message may only target an owned bubble — a prompt-injected
|
|
137
|
+
// edit_message can't tamper with a message it didn't send, or another
|
|
138
|
+
// session's bubble (review 2026-06-12). Bounded per session (insertion-order
|
|
139
|
+
// eviction) so a long session can't grow it without limit.
|
|
140
|
+
const ownedMessageIds = new Map(); // sessionKey → Set<number>
|
|
141
|
+
const rememberOwned = (sk, id) => {
|
|
142
|
+
if (id == null || sk == null) return;
|
|
143
|
+
const n = Number(id);
|
|
144
|
+
if (!Number.isFinite(n)) return;
|
|
145
|
+
let set = ownedMessageIds.get(sk);
|
|
146
|
+
if (!set) { set = new Set(); ownedMessageIds.set(sk, set); }
|
|
147
|
+
set.add(n);
|
|
148
|
+
while (set.size > OWNED_MSG_CAP) set.delete(set.values().next().value);
|
|
149
|
+
};
|
|
150
|
+
const ownsMessage = (sk, id) => ownedMessageIds.get(sk)?.has(Number(id)) === true;
|
|
151
|
+
|
|
127
152
|
return async function channelsToolDispatcher(call) {
|
|
128
|
-
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes } = call;
|
|
153
|
+
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes, messageId } = call;
|
|
154
|
+
|
|
155
|
+
// 0.13: `edit_message` edits a previously-sent bubble in place — the
|
|
156
|
+
// progressive-status primitive. claude gets the message_id from the
|
|
157
|
+
// `reply` tool's result (returned below) and edits it as work proceeds.
|
|
158
|
+
// A single edit is one bubble (no chunking); the `send` wrapper already
|
|
159
|
+
// does markdown→HTML and tolerates "message is not modified".
|
|
160
|
+
if (toolName === 'edit_message') {
|
|
161
|
+
if (!chatId) return { ok: false, error: 'edit_message.chat_id missing' };
|
|
162
|
+
if (messageId == null) return { ok: false, error: 'edit_message.message_id missing' };
|
|
163
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
164
|
+
return { ok: false, error: 'edit_message.text missing or empty' };
|
|
165
|
+
}
|
|
166
|
+
// Same agent-text hygiene as reply, minus chunking/delivery: strip inline
|
|
167
|
+
// [sticker:…]/[react:…] markers and intercept canned strings so neither
|
|
168
|
+
// leaks as literal text into the edited bubble.
|
|
169
|
+
let clean = text;
|
|
170
|
+
try { const p = parseResponse(text); if (p && typeof p.text === 'string') clean = p.text; } catch { /* keep raw */ }
|
|
171
|
+
try { const s = sanitizeAssistantReply(clean); if (s && typeof s.text === 'string') clean = s.text; } catch { /* keep */ }
|
|
172
|
+
if (clean.length === 0) return { ok: false, error: 'edit_message.text empty after sanitize' };
|
|
173
|
+
if (clean.length > maxChunkLen) {
|
|
174
|
+
return { ok: false, error: `edit text too long (${clean.length} > ${maxChunkLen}); a message edit is a single bubble — use reply for long content` };
|
|
175
|
+
}
|
|
176
|
+
// Ownership gate (review 2026-06-12): only edit a bubble THIS session
|
|
177
|
+
// created (got its id from a reply/edit result). Blocks a prompt-injected
|
|
178
|
+
// edit_message from tampering with an arbitrary or cross-session message.
|
|
179
|
+
if (!ownsMessage(sessionKey, messageId)) {
|
|
180
|
+
logger.warn?.(`[channels-tool-dispatcher] ${sessionKey} edit_message DENIED: message_id ${messageId} not owned by this session`);
|
|
181
|
+
return { ok: false, error: `message_id ${messageId} was not created by this session — edit_message can only target a bubble you sent` };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const params = { chat_id: chatId, message_id: messageId, text: clean };
|
|
185
|
+
if (threadId) params.message_thread_id = threadId;
|
|
186
|
+
await send(bot, 'editMessageText', params, { source: 'channels-tool-dispatcher', sessionKey, toolName });
|
|
187
|
+
rememberOwned(sessionKey, messageId); // keep ownership across re-edits
|
|
188
|
+
return { ok: true, message_id: messageId };
|
|
189
|
+
} catch (err) {
|
|
190
|
+
logger.error?.(`[channels-tool-dispatcher] ${sessionKey} edit_message failed: ${err.message}`);
|
|
191
|
+
return { ok: false, error: err.message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
129
194
|
|
|
130
195
|
if (toolName !== 'reply') {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
196
|
+
// `reply` + `edit_message` route here. `react` (and any future tool)
|
|
197
|
+
// is not yet implemented (Decision #10 / 0.13 follow-on).
|
|
133
198
|
return { ok: false, error: `unsupported tool: ${toolName}` };
|
|
134
199
|
}
|
|
135
200
|
|
|
@@ -171,6 +236,19 @@ function createChannelsToolDispatcher({
|
|
|
171
236
|
return { ok: false, error: `delivered ${dr.sent?.length || 0} of ${totalChunks} chunks; failed: ${failedDetail}` };
|
|
172
237
|
}
|
|
173
238
|
|
|
239
|
+
// 0.13: surface the FIRST delivered bubble's message_id so claude can
|
|
240
|
+
// edit_message it for progressive status. deliver.js pushes numeric ids;
|
|
241
|
+
// tolerate the {message_id} object shape too. null for solo sticker/reaction.
|
|
242
|
+
const firstSent = dr && Array.isArray(dr.sent) ? dr.sent.find(x => x != null) : null;
|
|
243
|
+
const replyMessageId = (firstSent && typeof firstSent === 'object') ? firstSent.message_id : firstSent;
|
|
244
|
+
// Remember every delivered bubble so claude can edit_message any of them
|
|
245
|
+
// (the ownership gate above) — not just the first one returned.
|
|
246
|
+
if (dr && Array.isArray(dr.sent)) {
|
|
247
|
+
for (const s of dr.sent) {
|
|
248
|
+
rememberOwned(sessionKey, (s && typeof s === 'object') ? s.message_id : s);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
174
252
|
// File attachments — sent as separate messages AFTER the text.
|
|
175
253
|
// Photos for image MIMEs, Documents for everything else (matches
|
|
176
254
|
// the official Telegram channels plugin behavior).
|
|
@@ -181,12 +259,22 @@ function createChannelsToolDispatcher({
|
|
|
181
259
|
// keys, and AWS creds can't leak to Telegram.
|
|
182
260
|
const failedAttachments = [];
|
|
183
261
|
if (Array.isArray(files) && files.length > 0) {
|
|
262
|
+
// Cap the per-reply fan-out (review 2026-06-12): a single (possibly
|
|
263
|
+
// injected) call attaching dozens of files would bypass the per-call
|
|
264
|
+
// rate limit. Process the first MAX_FILES_PER_REPLY; surface the rest.
|
|
265
|
+
let toAttach = files;
|
|
266
|
+
if (files.length > MAX_FILES_PER_REPLY) {
|
|
267
|
+
toAttach = files.slice(0, MAX_FILES_PER_REPLY);
|
|
268
|
+
for (const extra of files.slice(MAX_FILES_PER_REPLY)) {
|
|
269
|
+
failedAttachments.push({ path: extra, error: `too many files in one reply (max ${MAX_FILES_PER_REPLY}); send the rest in a follow-up` });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
184
272
|
const allowedRoots = buildAllowedRoots({
|
|
185
273
|
sessionKey,
|
|
186
274
|
sessionCwd: call.sessionCwd,
|
|
187
275
|
extraRoots: attachmentAllowlist,
|
|
188
276
|
});
|
|
189
|
-
for (const filePath of
|
|
277
|
+
for (const filePath of toAttach) {
|
|
190
278
|
const check = validateAttachmentPath(filePath, allowedRoots);
|
|
191
279
|
if (!check.ok) {
|
|
192
280
|
logger.warn?.(
|
|
@@ -241,7 +329,7 @@ function createChannelsToolDispatcher({
|
|
|
241
329
|
failedAttachments.map(f => `${f.path} (${f.error})`).join('; '),
|
|
242
330
|
};
|
|
243
331
|
}
|
|
244
|
-
return { ok: true };
|
|
332
|
+
return { ok: true, message_id: replyMessageId ?? null };
|
|
245
333
|
} catch (err) {
|
|
246
334
|
logger.error?.(`[channels-tool-dispatcher] ${sessionKey} dispatch failed: ${err.message}`);
|
|
247
335
|
return { ok: false, error: err.message };
|
|
@@ -262,10 +350,15 @@ function createChannelsToolDispatcher({
|
|
|
262
350
|
* is the safe default.
|
|
263
351
|
*/
|
|
264
352
|
function buildAllowedRoots({ sessionKey, sessionCwd = null, extraRoots = [] }) {
|
|
265
|
-
const roots = [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
353
|
+
const roots = [];
|
|
354
|
+
// SECURITY (review 2026-06-12): allow ONLY this session's own staging subdir,
|
|
355
|
+
// never the shared DEFAULT_ATTACHMENT_BASE parent. All sessions' claude procs
|
|
356
|
+
// run as the same uid, so a base-level root let session A send a file staged
|
|
357
|
+
// under session B (/tmp/polygram-attachments/<B>/…) → cross-chat exfiltration.
|
|
358
|
+
// A falsy sessionKey would collapse path.join(BASE,'') back to BASE, so skip
|
|
359
|
+
// the staging root entirely when it's missing (only sessionCwd/extras remain).
|
|
360
|
+
const sk = String(sessionKey || '');
|
|
361
|
+
if (sk) roots.push(path.join(DEFAULT_ATTACHMENT_BASE, sk));
|
|
269
362
|
if (sessionCwd) roots.push(sessionCwd);
|
|
270
363
|
if (Array.isArray(extraRoots)) {
|
|
271
364
|
for (const r of extraRoots) {
|