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.
Files changed (46) hide show
  1. package/config.example.json +3 -1
  2. package/lib/claude-bin.js +14 -1
  3. package/lib/compaction-warn.js +59 -0
  4. package/lib/context-usage.js +93 -0
  5. package/lib/db.js +1 -1
  6. package/lib/error/classify.js +33 -10
  7. package/lib/feedback/session-feedback.js +91 -0
  8. package/lib/handlers/abort.js +87 -40
  9. package/lib/handlers/autosteer.js +4 -0
  10. package/lib/handlers/config-callback.js +25 -6
  11. package/lib/handlers/config-ui.js +39 -10
  12. package/lib/handlers/dispatcher.js +83 -0
  13. package/lib/handlers/download.js +101 -58
  14. package/lib/handlers/drop-redeliver.js +69 -0
  15. package/lib/handlers/edit-correction.js +2 -0
  16. package/lib/handlers/edit-redelivery.js +136 -0
  17. package/lib/handlers/gate-inbound.js +188 -0
  18. package/lib/handlers/questions.js +289 -0
  19. package/lib/handlers/redeliver.js +122 -0
  20. package/lib/handlers/slash-commands.js +43 -30
  21. package/lib/history-preload.js +6 -0
  22. package/lib/history.js +7 -1
  23. package/lib/model-costs.js +4 -0
  24. package/lib/process/channels-bridge-protocol.js +22 -1
  25. package/lib/process/channels-bridge.mjs +128 -7
  26. package/lib/process/channels-tool-dispatcher.js +105 -12
  27. package/lib/process/cli-process.js +1277 -70
  28. package/lib/process/hook-event-tail.js +7 -0
  29. package/lib/process/hook-settings.js +7 -0
  30. package/lib/process/process.js +22 -0
  31. package/lib/process-guard.js +57 -1
  32. package/lib/process-manager.js +120 -35
  33. package/lib/questions/questions.js +187 -0
  34. package/lib/questions/store.js +105 -0
  35. package/lib/rewind/execute.js +89 -0
  36. package/lib/rewind/fork.js +112 -0
  37. package/lib/rewind/rewind.js +174 -0
  38. package/lib/sdk/callbacks.js +165 -167
  39. package/lib/session-key.js +29 -0
  40. package/lib/telegram/album-reactions.js +50 -0
  41. package/lib/telegram/parse.js +9 -2
  42. package/lib/telegram/typing.js +17 -2
  43. package/lib/tmux/startup-gate.js +44 -14
  44. package/migrations/012-pending-questions.sql +30 -0
  45. package/package.json +1 -1
  46. package/polygram.js +224 -78
@@ -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
- const oldModel = chatConfig.model;
194
- chatConfig.model = newModel;
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: threadIdStr, field: 'model',
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
- // Review F#10: channels backend can't apply model/effort changes
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 oldEffort = chatConfig.effort;
225
- chatConfig.effort = newEffort;
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: threadIdStr, field: 'effort',
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
- // Review F#10: channels backend can't apply model/effort changes
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
  }
@@ -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); }
@@ -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
- name: z.enum(['reply', 'react', 'edit_message']),
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
- ok ? p.resolve() : p.reject(new Error(error || 'daemon rejected delivery'))
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
- return { content: [{ type: 'text', text: 'sent' }] }
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}` — CliProcess
13
- * relays this to the bridge as tool_ack, which surfaces to Claude as the
14
- * tool's return value (`'sent'` on ok, error message on failure).
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
- // 0.11.0 Phase 1 ships `reply` only react and edit_message are
132
- // deferred (Decision #10). Future tools route through here too.
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 files) {
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
- DEFAULT_ATTACHMENT_BASE,
267
- path.join(DEFAULT_ATTACHMENT_BASE, String(sessionKey || '')),
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) {