polygram 0.12.0-rc.30 → 0.12.0-rc.31

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.
@@ -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); }
@@ -121,6 +121,10 @@ const ToolAckMessageSchema = z.object({
121
121
  tool_call_id: ToolCallId,
122
122
  ok: z.boolean(),
123
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(),
124
128
  }).passthrough();
125
129
 
126
130
  // 0.12 interactive questions: carries the user's answer back for an `ask` tool
@@ -108,19 +108,22 @@ 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'))
117
119
  }
118
120
 
119
121
  // ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
120
- // Separate from tool_ack: a question can take MINUTES (the daemon-side 8-min
121
- // question timeout resolves it with {timedout} well before this hard ceiling).
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).
122
125
  const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
123
- const QUESTION_ANSWER_TIMEOUT_MS = 20 * 60 * 1000
126
+ const QUESTION_ANSWER_TIMEOUT_MS = 32 * 60 * 1000
124
127
 
125
128
  function awaitQuestionAnswer(toolCallId) {
126
129
  return new Promise((resolve) => {
@@ -235,7 +238,7 @@ function handleDaemonMessage(msg) {
235
238
  break
236
239
 
237
240
  case 'tool_ack':
238
- resolveToolAck(msg.tool_call_id, msg.ok, msg.error)
241
+ resolveToolAck(msg.tool_call_id, msg.ok, msg.error, msg.message_id)
239
242
  break
240
243
 
241
244
  case 'question_answer':
@@ -301,7 +304,9 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
301
304
  description: 'Send a message back to the originating Telegram chat. ' +
302
305
  'chat_id MUST match the chat_id from the inbound <channel> tag. ' +
303
306
  'turn_id MUST echo the turn_id from the inbound <channel> tag (when present) ' +
304
- 'so concurrent turns route their replies correctly.',
307
+ 'so concurrent turns route their replies correctly. ' +
308
+ 'Returns {ok, message_id}: keep the message_id to update that bubble in place ' +
309
+ 'with `edit_message` (progressive status on long tasks).',
305
310
  inputSchema: {
306
311
  type: 'object',
307
312
  properties: {
@@ -312,6 +317,25 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
312
317
  },
313
318
  required: ['chat_id', 'text'],
314
319
  },
320
+ }, {
321
+ // 0.13: edit a message already sent via `reply`, in place — the progressive-
322
+ // status primitive. Update one bubble instead of sending several.
323
+ name: 'edit_message',
324
+ description: 'Edit a message you previously sent via `reply`, in place. Use this for ' +
325
+ 'progressive status on a long task: send a short status with `reply`, take the ' +
326
+ 'returned message_id, then `edit_message` it as you make progress (ending with ' +
327
+ 'the final answer). Keep status in PLAIN LANGUAGE — never tool names like Bash/Edit. ' +
328
+ 'One bubble only (no chunking); for long content use `reply` instead.',
329
+ inputSchema: {
330
+ type: 'object',
331
+ properties: {
332
+ chat_id: { type: 'string', description: 'Echo of chat_id from inbound channel meta.' },
333
+ turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta.' },
334
+ message_id: { type: 'number', description: 'The message_id returned by the `reply` tool call you want to update.' },
335
+ text: { type: 'string', description: 'New full message body (markdown ok) — replaces the old text.' },
336
+ },
337
+ required: ['chat_id', 'message_id', 'text'],
338
+ },
315
339
  }, {
316
340
  // 0.12 interactive questions: ask the Telegram user a multiple-choice question
317
341
  // as tap-to-answer inline buttons. USE THIS instead of any interactive menu —
@@ -355,7 +379,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
355
379
  })
356
380
 
357
381
  mcp.setRequestHandler(CallToolRequestSchema, async req => {
358
- if (req.params.name !== 'reply' && req.params.name !== 'ask') {
382
+ if (req.params.name !== 'reply' && req.params.name !== 'ask' && req.params.name !== 'edit_message') {
359
383
  return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
360
384
  }
361
385
  const toolCallId = randomUUID()
@@ -385,8 +409,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
385
409
  args: req.params.arguments,
386
410
  }) + '\n')
387
411
  try {
388
- await ackP
389
- return { content: [{ type: 'text', text: 'sent' }] }
412
+ const ack = await ackP
413
+ // Return {ok, message_id} as JSON so claude can read the delivered bubble's
414
+ // id and `edit_message` it later (progressive status). For a plain reply with
415
+ // no id (solo sticker/reaction) message_id is null.
416
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: ack?.message_id ?? null }) }] }
390
417
  } catch (err) {
391
418
  return { content: [{ type: 'text', text: `delivery failed: ${err.message}` }], isError: true }
392
419
  }
@@ -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
@@ -125,11 +126,43 @@ function createChannelsToolDispatcher({
125
126
  || require('../telegram/process-agent-reply').processAndDeliverAgentText;
126
127
 
127
128
  return async function channelsToolDispatcher(call) {
128
- const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes } = call;
129
+ const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes, messageId } = call;
130
+
131
+ // 0.13: `edit_message` edits a previously-sent bubble in place — the
132
+ // progressive-status primitive. claude gets the message_id from the
133
+ // `reply` tool's result (returned below) and edits it as work proceeds.
134
+ // A single edit is one bubble (no chunking); the `send` wrapper already
135
+ // does markdown→HTML and tolerates "message is not modified".
136
+ if (toolName === 'edit_message') {
137
+ if (!chatId) return { ok: false, error: 'edit_message.chat_id missing' };
138
+ if (messageId == null) return { ok: false, error: 'edit_message.message_id missing' };
139
+ if (typeof text !== 'string' || text.length === 0) {
140
+ return { ok: false, error: 'edit_message.text missing or empty' };
141
+ }
142
+ // Same agent-text hygiene as reply, minus chunking/delivery: strip inline
143
+ // [sticker:…]/[react:…] markers and intercept canned strings so neither
144
+ // leaks as literal text into the edited bubble.
145
+ let clean = text;
146
+ try { const p = parseResponse(text); if (p && typeof p.text === 'string') clean = p.text; } catch { /* keep raw */ }
147
+ try { const s = sanitizeAssistantReply(clean); if (s && typeof s.text === 'string') clean = s.text; } catch { /* keep */ }
148
+ if (clean.length === 0) return { ok: false, error: 'edit_message.text empty after sanitize' };
149
+ if (clean.length > maxChunkLen) {
150
+ return { ok: false, error: `edit text too long (${clean.length} > ${maxChunkLen}); a message edit is a single bubble — use reply for long content` };
151
+ }
152
+ try {
153
+ const params = { chat_id: chatId, message_id: messageId, text: clean };
154
+ if (threadId) params.message_thread_id = threadId;
155
+ await send(bot, 'editMessageText', params, { source: 'channels-tool-dispatcher', sessionKey, toolName });
156
+ return { ok: true, message_id: messageId };
157
+ } catch (err) {
158
+ logger.error?.(`[channels-tool-dispatcher] ${sessionKey} edit_message failed: ${err.message}`);
159
+ return { ok: false, error: err.message };
160
+ }
161
+ }
129
162
 
130
163
  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.
164
+ // `reply` + `edit_message` route here. `react` (and any future tool)
165
+ // is not yet implemented (Decision #10 / 0.13 follow-on).
133
166
  return { ok: false, error: `unsupported tool: ${toolName}` };
134
167
  }
135
168
 
@@ -171,6 +204,12 @@ function createChannelsToolDispatcher({
171
204
  return { ok: false, error: `delivered ${dr.sent?.length || 0} of ${totalChunks} chunks; failed: ${failedDetail}` };
172
205
  }
173
206
 
207
+ // 0.13: surface the FIRST delivered bubble's message_id so claude can
208
+ // edit_message it for progressive status. deliver.js pushes numeric ids;
209
+ // tolerate the {message_id} object shape too. null for solo sticker/reaction.
210
+ const firstSent = dr && Array.isArray(dr.sent) ? dr.sent.find(x => x != null) : null;
211
+ const replyMessageId = (firstSent && typeof firstSent === 'object') ? firstSent.message_id : firstSent;
212
+
174
213
  // File attachments — sent as separate messages AFTER the text.
175
214
  // Photos for image MIMEs, Documents for everything else (matches
176
215
  // the official Telegram channels plugin behavior).
@@ -241,7 +280,7 @@ function createChannelsToolDispatcher({
241
280
  failedAttachments.map(f => `${f.path} (${f.error})`).join('; '),
242
281
  };
243
282
  }
244
- return { ok: true };
283
+ return { ok: true, message_id: replyMessageId ?? null };
245
284
  } catch (err) {
246
285
  logger.error?.(`[channels-tool-dispatcher] ${sessionKey} dispatch failed: ${err.message}`);
247
286
  return { ok: false, error: err.message };
@@ -281,6 +281,7 @@ class CliProcess extends Process {
281
281
  // doesn't re-invoke the dispatcher → duplicate TG send. Set is bounded
282
282
  // to RECENT_TOOL_CALL_LIMIT entries via FIFO eviction.
283
283
  this.recentToolCallIds = new Set();
284
+ this.recentToolCallResults = new Map(); // tool_call_id → message_id (0.13: replay on re-ACK)
284
285
  this.recentToolCallOrder = []; // FIFO bound
285
286
  // Review F#17: per-pattern last-fired timestamp for the mid-turn dialog
286
287
  // watchdog. Dedups within MID_TURN_DEDUP_WINDOW_MS so a lingering dialog
@@ -695,6 +696,20 @@ class CliProcess extends Process {
695
696
  'as normal — only the FINAL user-visible message needs to go through',
696
697
  'the reply tool.',
697
698
  '',
699
+ '### Staying responsive on a long task',
700
+ '',
701
+ 'The user cannot see you working — no live typing reaches them. For any task',
702
+ 'that takes more than a few seconds, send a SHORT status first via `reply`',
703
+ '(it returns a `message_id`), then call `mcp__polygram-bridge__edit_message`',
704
+ 'with that `message_id` to update the SAME bubble as you make progress,',
705
+ 'finishing with the result. One evolving message beats silence or a flood of',
706
+ 'new ones.',
707
+ '',
708
+ 'Write status in PLAIN, friendly language about what you are doing FOR THE',
709
+ 'USER — never tool names or mechanics. Say "Checking your config now…", not',
710
+ '"Running Bash" or "Calling Read". If the final answer is long, send it as a',
711
+ 'fresh `reply` rather than an edit (an edit is one single message bubble).',
712
+ '',
698
713
  // TEMPORARY mitigation (2026-06-08 Shumabit@UMI wedge): AskUserQuestion opens
699
714
  // a blocking TUI selection widget the channel can't answer → the session
700
715
  // parks until manually Esc'd. REMOVE this whole rule when the rich
@@ -1007,7 +1022,9 @@ class CliProcess extends Process {
1007
1022
  this.logger.warn?.(
1008
1023
  `[${this.label}] channels: duplicate tool_call_id=${msg.tool_call_id} — re-ACKing without dispatch`,
1009
1024
  );
1010
- this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true });
1025
+ // 0.13: replay the cached message_id so a retried reply keeps its edit handle
1026
+ // (re-ACKing without it would null the handle → progressive status silently breaks).
1027
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: this.recentToolCallResults.get(msg.tool_call_id) ?? null });
1011
1028
  return;
1012
1029
  }
1013
1030
 
@@ -1041,15 +1058,15 @@ class CliProcess extends Process {
1041
1058
  // an isError ack). Window-based so legit repeat sends eventually pass.
1042
1059
  if (msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
1043
1060
  const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
1044
- const expiry = this.recentContentHashes.get(dedupKey);
1061
+ const entry = this.recentContentHashes.get(dedupKey); // { expiry, message_id }
1045
1062
  const nowDedup = Date.now();
1046
1063
  // Evict stale entries opportunistically (avoids unbounded growth).
1047
1064
  if (this.recentContentHashes.size > 64) {
1048
- for (const [k, ts] of this.recentContentHashes) {
1049
- if (ts < nowDedup) this.recentContentHashes.delete(k);
1065
+ for (const [k, e] of this.recentContentHashes) {
1066
+ if (e.expiry < nowDedup) this.recentContentHashes.delete(k);
1050
1067
  }
1051
1068
  }
1052
- if (expiry && expiry > nowDedup) {
1069
+ if (entry && entry.expiry > nowDedup) {
1053
1070
  this.logger.warn?.(
1054
1071
  `[${this.label}] channels: duplicate content within ${this.contentDedupWindowMs}ms ` +
1055
1072
  `(new tool_call_id=${msg.tool_call_id}, hash=${dedupKey.slice(-12)}) — re-ACKing without dispatch`,
@@ -1059,7 +1076,9 @@ class CliProcess extends Process {
1059
1076
  chat_id: args.chat_id,
1060
1077
  window_ms: this.contentDedupWindowMs,
1061
1078
  });
1062
- this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true });
1079
+ // 0.13: replay the ORIGINAL bubble's message_id so a retried identical reply
1080
+ // keeps its edit handle (the slow-ack-retry case progressive status targets).
1081
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: true, message_id: entry.message_id ?? null });
1063
1082
  return;
1064
1083
  }
1065
1084
  }
@@ -1111,6 +1130,7 @@ class CliProcess extends Process {
1111
1130
  toolName: msg.name,
1112
1131
  text: args.text,
1113
1132
  files: args.files,
1133
+ messageId: args.message_id, // 0.13: edit_message target bubble
1114
1134
  sessionCwd: this.sessionCwd, // P0 #2: dispatcher uses this to allowlist file roots
1115
1135
  maxOutboundFileBytes: this.maxOutboundFileBytes, // backend/chat-derived upload cap
1116
1136
  });
@@ -1119,18 +1139,22 @@ class CliProcess extends Process {
1119
1139
  return;
1120
1140
  }
1121
1141
 
1122
- this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error });
1142
+ // 0.13: carry the delivered message_id back so the bridge hands it to claude
1143
+ // (reply → edit_message progressive status).
1144
+ this._writeToBridge({ kind: 'tool_ack', tool_call_id: msg.tool_call_id, ok: !!result?.ok, error: result?.error, message_id: result?.message_id });
1123
1145
 
1124
1146
  // P1 #7: remember the tool_call_id so duplicates re-ACK without dispatch.
1125
1147
  // Only cache on SUCCESS — failed calls should be retryable (transient TG
1126
1148
  // outage etc).
1127
1149
  if (result?.ok && msg.tool_call_id) {
1128
1150
  this.recentToolCallIds.add(msg.tool_call_id);
1151
+ this.recentToolCallResults.set(msg.tool_call_id, result.message_id ?? null); // 0.13: for re-ACK replay
1129
1152
  this.recentToolCallOrder.push(msg.tool_call_id);
1130
1153
  // FIFO eviction at cap
1131
1154
  while (this.recentToolCallOrder.length > RECENT_TOOL_CALL_LIMIT) {
1132
1155
  const evicted = this.recentToolCallOrder.shift();
1133
1156
  this.recentToolCallIds.delete(evicted);
1157
+ this.recentToolCallResults.delete(evicted);
1134
1158
  }
1135
1159
  }
1136
1160
 
@@ -1138,7 +1162,9 @@ class CliProcess extends Process {
1138
1162
  // NEW tool_call_id still dedups. TTL-based via expiry timestamp.
1139
1163
  if (result?.ok && msg.name === 'reply' && typeof args.text === 'string' && args.chat_id != null) {
1140
1164
  const dedupKey = this._buildContentDedupKey(args.chat_id, args.text);
1141
- this.recentContentHashes.set(dedupKey, Date.now() + this.contentDedupWindowMs);
1165
+ // 0.13: store the delivered message_id alongside the expiry so a deduped retry
1166
+ // can replay it (keeps claude's edit handle for progressive status).
1167
+ this.recentContentHashes.set(dedupKey, { expiry: Date.now() + this.contentDedupWindowMs, message_id: result.message_id ?? null });
1142
1168
  }
1143
1169
 
1144
1170
  // Review #16 + C9: only record the reply for pending-turn resolution when
@@ -193,7 +193,60 @@ function _makeUnhandledRejectionHandler(opts) {
193
193
  }
194
194
 
195
195
  /**
196
- * Convenience: install both handlers in one call.
196
+ * Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
197
+ * shutdown can't become an uncaughtException.
198
+ *
199
+ * rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
200
+ * write errors themselves still surface: when `launchctl kickstart` destroys the
201
+ * tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
202
+ * Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
203
+ * unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
204
+ * restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
205
+ * every deploy, interrupting the graceful drain.
206
+ *
207
+ * This guards BOTH delivery paths:
208
+ * (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
209
+ * (b) async: attaches an `error` listener for the pipe case (errors arrive as
210
+ * events). Genuine, non-EPIPE/EIO errors still surface unchanged.
211
+ *
212
+ * @returns {{ uninstall: function() }}
213
+ */
214
+ function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
215
+ const guarded = streams.filter(Boolean);
216
+ const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
217
+ const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
218
+ const restores = [];
219
+
220
+ for (const s of guarded) {
221
+ s.on?.('error', onError);
222
+ restores.push(() => s.off?.('error', onError));
223
+
224
+ if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
225
+ const origWrite = s.write;
226
+ s.write = function guardedWrite(...args) {
227
+ try {
228
+ return origWrite.apply(this, args);
229
+ } catch (err) {
230
+ if (!isBrokenPipe(err)) throw err;
231
+ // Pane is gone — nothing to write to. Invoke the write callback (the
232
+ // last arg, if any) so callers awaiting it don't hang, and report
233
+ // backpressure (false) instead of throwing to uncaughtException.
234
+ const cb = args[args.length - 1];
235
+ if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
236
+ return false;
237
+ }
238
+ };
239
+ s.__polygramStdioGuarded = true;
240
+ restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
241
+ }
242
+ }
243
+
244
+ return { uninstall() { for (const r of restores) r(); } };
245
+ }
246
+
247
+ /**
248
+ * Convenience: install both handlers in one call (plus the stdio guard, so the
249
+ * shutdown broken-pipe writes never reach the uncaughtException handler).
197
250
  * @returns {{ uninstall: function() }}
198
251
  */
199
252
  function installSafetyHandlers(opts) {
@@ -201,10 +254,12 @@ function installSafetyHandlers(opts) {
201
254
  const onRejection = _makeUnhandledRejectionHandler(opts);
202
255
  process.on('uncaughtException', onException);
203
256
  process.on('unhandledRejection', onRejection);
257
+ const stdio = guardStdio();
204
258
  return {
205
259
  uninstall() {
206
260
  process.off('uncaughtException', onException);
207
261
  process.off('unhandledRejection', onRejection);
262
+ stdio.uninstall();
208
263
  },
209
264
  };
210
265
  }
@@ -235,6 +290,7 @@ module.exports = {
235
290
  claimPidFile,
236
291
  releasePidFile,
237
292
  installSafetyHandlers,
293
+ guardStdio,
238
294
  _makeUncaughtHandler,
239
295
  _makeUnhandledRejectionHandler,
240
296
  };
@@ -11,7 +11,12 @@
11
11
 
12
12
  const { newToken, tokensEqual } = require('../approvals/store');
13
13
 
14
- const DEFAULT_TIMEOUT_MS = 8 * 60 * 1000; // under the 10-min idle / 30-min absolute turn caps
14
+ // Option A (2026-06-09): don't expire a question before the turn that's blocking on
15
+ // it can. A blocking `ask` can live at most the 30-min turn ABSOLUTE cap
16
+ // (DEFAULT_TURN_ABSOLUTE_MS) — the keep-alive resets the idle cap but not the absolute
17
+ // — so align here. The user answers any time within the turn's life, not an arbitrary
18
+ // 8-min window. (Truly-unbounded "answer hours later" needs the non-blocking redesign.)
19
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
15
20
 
16
21
  function createQuestionStore(rawDb, now = () => Date.now()) {
17
22
  const insertStmt = rawDb.prepare(`
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const { buildFork } = require('./fork');
8
+
9
+ // claude's transcript path for a session: ~/.claude/projects/<cwd '/'→'-'>/<id>.jsonl.
10
+ // CRITICAL (Finding A): mangle the RAW cwd, EXACTLY as cli-process.js's resume check does
11
+ // (`resolvedCwd.replace(/\//g,'-')`, no realpath). The fork is written to this path and the
12
+ // resume pre-check looks for it at this path — if the two diverge (one realpaths a symlinked
13
+ // cwd, the other doesn't) the resume misses the fork and claude starts a fresh empty session:
14
+ // a SILENT full-context wipe. Keeping both raw keeps them in lockstep; a genuinely symlinked
15
+ // cwd then fails cleanly at the read step (transcript unreadable) instead of wiping.
16
+ function transcriptPathFor(cwd, sessionId) {
17
+ return path.join(os.homedir(), '.claude', 'projects', String(cwd).replace(/\//g, '-'), `${sessionId}.jsonl`);
18
+ }
19
+
20
+ /**
21
+ * The 0.13 /rewind executor (P2/P3). Forks the live session transcript to before the target
22
+ * message, points the session at the fork (so the next message resumes the rewound
23
+ * conversation), kills the live proc, and deletes the bot's now-orphaned outbound messages.
24
+ * Copy-only + fail-safe: a fork failure leaves the original session fully intact.
25
+ *
26
+ * @param {object} deps { db, pm, tg, bot, botName, logEvent, logger, buildForkImpl }
27
+ * @returns {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>}
28
+ */
29
+ function createRewindExecutor({ db, pm, tg, bot, botName = 'bot', logEvent = () => {}, logger = console, buildForkImpl = buildFork } = {}) {
30
+ // P3: delete the bot's own outbound messages sent after M (it can always delete its own).
31
+ async function deleteBotMessagesAfter({ chatId, threadId, afterMsgId }) {
32
+ let rows;
33
+ try {
34
+ const sql = `SELECT msg_id FROM messages WHERE chat_id = ? AND direction = 'out' AND status = 'sent' AND msg_id > ?`
35
+ + (threadId ? ` AND thread_id = ?` : ` AND thread_id IS NULL`) + ` ORDER BY msg_id`;
36
+ const params = threadId ? [String(chatId), Number(afterMsgId), String(threadId)] : [String(chatId), Number(afterMsgId)];
37
+ rows = db.raw.prepare(sql).all(...params);
38
+ } catch (e) { logger.error?.(`[${botName}] rewind cleanup query failed: ${e.message}`); return 0; }
39
+ let deleted = 0;
40
+ for (const r of rows || []) {
41
+ try {
42
+ await tg(bot, 'deleteMessage', { chat_id: chatId, message_id: r.msg_id }, { source: 'rewind-cleanup', botName });
43
+ deleted++;
44
+ } catch { /* already gone / older than 48h — Telegram won't delete; skip */ }
45
+ }
46
+ return deleted;
47
+ }
48
+
49
+ return async function executeRewind(req) {
50
+ const row = db.getSession(req.sessionKey);
51
+ if (!row || !row.claude_session_id || !row.cwd) {
52
+ return { ok: false, error: 'no live session to rewind' };
53
+ }
54
+ const transcriptPath = transcriptPathFor(row.cwd, row.claude_session_id);
55
+ const newId = crypto.randomUUID();
56
+
57
+ const fork = buildForkImpl({ transcriptPath, targetMsgId: req.target.msg_id, newSessionId: newId });
58
+ if (!fork.ok) return { ok: false, error: fork.error }; // original untouched
59
+
60
+ // Point the session at the fork → the next message lazy-resumes the rewound conversation.
61
+ try {
62
+ db.upsertSession({ ...row, claude_session_id: newId });
63
+ } catch (e) {
64
+ try { fs.unlinkSync(fork.forkPath); } catch {}
65
+ logger.error?.(`[${botName}] rewind id-swap failed: ${e.message}`);
66
+ return { ok: false, error: 'failed to record the rewind' };
67
+ }
68
+ // Drop the live proc (it holds the OLD session); next inbound message respawns on the fork.
69
+ // A kill failure (Finding E) is NOT silent: the DB already points at the fork, but the old
70
+ // proc may survive holding the old id (a two-transcript split), so surface a warning rather
71
+ // than report a clean success the operator would trust blindly.
72
+ let warning;
73
+ try { await pm.kill(req.sessionKey, 'rewind'); }
74
+ catch (e) {
75
+ warning = 'the previous session may still be running — send a new message to confirm the rewind took';
76
+ logger.error?.(`[${botName}] rewind kill: ${e.message}`);
77
+ logEvent('rewind-kill-failed', { session_key: req.sessionKey, error: e.message });
78
+ }
79
+
80
+ let droppedCount = 0;
81
+ try { droppedCount = await deleteBotMessagesAfter({ chatId: req.chatId, threadId: req.threadId, afterMsgId: req.target.msg_id }); }
82
+ catch (e) { logger.error?.(`[${botName}] rewind cleanup failed: ${e.message}`); }
83
+
84
+ logEvent('rewind-executed', { session_key: req.sessionKey, new_id: newId, target_msg_id: req.target.msg_id, dropped_turns: fork.droppedTurns });
85
+ return { ok: true, droppedCount, ...(warning && { warning }) };
86
+ };
87
+ }
88
+
89
+ module.exports = { createRewindExecutor, transcriptPathFor };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Build a rewound FORK of a claude session transcript (0.13 /rewind P2; the mechanism P0.6
8
+ * validated). Keep the prefix up to — not including — the user turn carrying `targetMsgId`,
9
+ * rewrite the in-file `sessionId` to `newSessionId` on every line, and write a new
10
+ * `<newSessionId>.jsonl` next to the original (mode 0o600). NEVER touches the original.
11
+ *
12
+ * Fail-safe by construction — any anomaly returns `{ ok:false, error }`, never a partial/
13
+ * corrupt fork:
14
+ * - transcript unreadable / not valid JSONL
15
+ * - target not found (scrolled out OR compacted away — the locate-miss IS the compaction
16
+ * guard: a target older than a compaction boundary no longer carries its msg_id wrapper)
17
+ * - target is already the conversation start
18
+ * - the cut point is mid-tool-call (a tool_use in the prefix without its tool_result)
19
+ *
20
+ * A clean prefix needs no `parentUuid` reconciliation — a prefix of a backward-linked chain
21
+ * is self-consistent (verified in the P0.6 spike).
22
+ *
23
+ * @param {object} args
24
+ * @param {string} args.transcriptPath
25
+ * @param {string|number} args.targetMsgId — TG msg_id of the user message to rewind to.
26
+ * @param {string} args.newSessionId
27
+ * @param {object} [io] { fsImpl } — inject a fake fs for tests.
28
+ * @returns {{ ok:true, forkPath:string, droppedTurns:number } | { ok:false, error:string }}
29
+ */
30
+ function buildFork({ transcriptPath, targetMsgId, newSessionId }, { fsImpl = fs } = {}) {
31
+ if (!transcriptPath || !newSessionId || targetMsgId == null) {
32
+ return { ok: false, error: 'buildFork: transcriptPath, targetMsgId, newSessionId required' };
33
+ }
34
+ let raw;
35
+ try { raw = fsImpl.readFileSync(transcriptPath, 'utf8'); }
36
+ catch (e) { return { ok: false, error: `transcript unreadable: ${e.code || e.message}` }; }
37
+
38
+ const lines = String(raw).split('\n').filter((l) => l.trim());
39
+ let objs;
40
+ try { objs = lines.map((l) => JSON.parse(l)); }
41
+ catch { return { ok: false, error: 'transcript is not valid JSONL' }; }
42
+
43
+ // The channel wrapper lives in the user turn's content as a PLAIN string (the parsed value,
44
+ // not JSON-escaped) — search that, not JSON.stringify (which escapes the inner quotes).
45
+ const contentText = (o) => {
46
+ const c = o && o.message ? o.message.content : null;
47
+ if (typeof c === 'string') return c;
48
+ if (Array.isArray(c)) return c.map((b) => (b && b.type === 'text' ? b.text : '')).join(' ');
49
+ return '';
50
+ };
51
+ // Match the channel ENVELOPE's OWN msg_id — never a `<reply_to msg_id="X">` echoed inside a
52
+ // later turn's body (Finding B: silent-failure-hunter). In the parsed content only the outer
53
+ // bridge envelope keeps an unescaped `<channel …>`; the inner reply_to/telegram tags are
54
+ // escaped to `&lt;…&gt;`. The envelope's own closing `>` stops `[^>]*` before any embedded
55
+ // reply_to, so this matches the envelope whose msg_id IS the target and nothing else. Without
56
+ // this anchor, a target compacted away while a later turn still quotes it in reply_to would
57
+ // false-match the reply turn → a silent cut at the WRONG point, defeating the not-found guard.
58
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+ const targetRe = new RegExp(`<channel[^>]*\\bmsg_id="${escapeRe(targetMsgId)}"`);
60
+ const isChannelUser = (o) => o && o.type === 'user' && /<channel[^>]*\bmsg_id="/.test(contentText(o));
61
+ const cutIdx = objs.findIndex((o) => o && o.type === 'user' && targetRe.test(contentText(o)));
62
+ if (cutIdx < 0) {
63
+ return { ok: false, error: "couldn't find that message in the conversation (it may have scrolled out of memory)" };
64
+ }
65
+
66
+ const prefix = objs.slice(0, cutIdx);
67
+ if (!prefix.some(isChannelUser)) {
68
+ return { ok: false, error: "that's already the start of the conversation" };
69
+ }
70
+
71
+ // Clean-boundary check: no tool_use in the prefix left without its matching tool_result.
72
+ const openTools = new Set();
73
+ for (const o of prefix) {
74
+ const blocks = Array.isArray(o.message?.content) ? o.message.content : [];
75
+ for (const b of blocks) {
76
+ if (b && b.type === 'tool_use' && b.id) openTools.add(b.id);
77
+ if (b && b.type === 'tool_result' && b.tool_use_id) openTools.delete(b.tool_use_id);
78
+ }
79
+ }
80
+ if (openTools.size > 0) {
81
+ return { ok: false, error: 'that point is mid-tool-call — pick the message just before or after' };
82
+ }
83
+
84
+ // Rewrite sessionId on EVERY kept line (the resume id must match the filename + in-file id,
85
+ // else claude's ghost-session guard drops it and starts fresh = a silent full wipe).
86
+ const forked = prefix.map((o) => {
87
+ if (o && typeof o === 'object' && 'sessionId' in o) o.sessionId = newSessionId;
88
+ return JSON.stringify(o);
89
+ });
90
+
91
+ const dir = path.dirname(transcriptPath);
92
+ const forkPath = path.join(dir, `${newSessionId}.jsonl`);
93
+ // Atomic write (Finding C): a direct write to the live resume path can leave a TRUNCATED
94
+ // <id>.jsonl if interrupted (disk-full, crash, signal) — which claude then resumes as
95
+ // partial/empty context, or whose half-line trips the ghost-session guard into a full wipe.
96
+ // Write a temp sibling, then rename (atomic on the same fs) so the resume path only ever sees
97
+ // a complete file. The temp is a dotfile (won't match claude's `<sessionId>.jsonl` glob) and
98
+ // is cleaned on failure; a crash between write and rename leaves only a harmless orphan dot-tmp.
99
+ const tmpPath = path.join(dir, `.${newSessionId}.jsonl.tmp`);
100
+ try {
101
+ fsImpl.writeFileSync(tmpPath, forked.join('\n') + '\n', { mode: 0o600 });
102
+ fsImpl.renameSync(tmpPath, forkPath);
103
+ } catch (e) {
104
+ try { fsImpl.unlinkSync(tmpPath); } catch { /* nothing to clean */ }
105
+ return { ok: false, error: `couldn't write the fork: ${e.code || e.message}` };
106
+ }
107
+
108
+ const droppedTurns = objs.slice(cutIdx).filter(isChannelUser).length;
109
+ return { ok: true, forkPath, droppedTurns };
110
+ }
111
+
112
+ module.exports = { buildFork };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `/rewind` command — P1 (0.13). Reply to a message with `/rewind` to rewind the
5
+ * conversation to just before it. P1 ships the plumbing: detect → gate (operator +
6
+ * message-ownership, per the 2026-06-09 security review) → defer to turn-end → confirm.
7
+ * The actual transcript fork is the **injected `executeRewind`** — P2 provides the real
8
+ * one (see docs/0.13-rewind-design.md, B-safe). P0.6 proved the fork mechanism works.
9
+ *
10
+ * Scope boundary the confirmation states out loud: a rewind reverts the CONVERSATION,
11
+ * not real-world side-effects (Drive files, Sheets, emails already created persist).
12
+ */
13
+
14
+ // `/rewind` or `/rewind@botname`, alone on the line (no args).
15
+ const REWIND_RE = /^\/rewind(?:@\w+)?\s*$/i;
16
+
17
+ // Backstop for a deferred rewind whose proc emits neither 'idle' nor 'close'. Above the 30-min
18
+ // absolute turn cap so a legitimately long in-flight turn finishes (emitting 'idle') first.
19
+ const DEFER_TIMEOUT_MS = 31 * 60 * 1000;
20
+
21
+ function isRewindCommand(text) {
22
+ return REWIND_RE.test(String(text || '').trim());
23
+ }
24
+
25
+ /**
26
+ * Validate a `/rewind` request. Returns { ok, reason }. Gate order:
27
+ * 1. must reply-to a message (the rewind target M)
28
+ * 2. the chat must be rewind-SAFE — a DM or an isolate-topics group. A shared (non-isolated)
29
+ * group runs ONE session across all topics, so a rewind there would blast every topic and
30
+ * user; refuse it. (Caller computes `rewindSafe`.)
31
+ * 3. access policy: the operator/admin is always allowed; any *paired* user is allowed ONLY
32
+ * when the chat opted in with `rewindAccess: 'paired'`. Default ('operator') is operator-only
33
+ * — NOT "any paired user" (the security-review degradation when operatorUserId was unset).
34
+ * 4. M must be the sender's OWN message or one of the bot's own bubbles — never another user's
35
+ * (else an allowed user could rewind to anyone's message).
36
+ *
37
+ * @param {object} a
38
+ * @param {object} a.msg
39
+ * @param {string} a.botUsername
40
+ * @param {boolean} a.rewindSafe chat is a DM OR an isolateTopics group (caller computes)
41
+ * @param {boolean} a.isOperatorIdentity sender is operatorUserId or the admin user (caller computes)
42
+ * @param {boolean} a.paired sender has a live pairing in this chat
43
+ * @param {'operator'|'paired'} [a.accessMode='operator']
44
+ */
45
+ function gateRewindRequest({ msg, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode = 'operator' } = {}) {
46
+ const reply = msg?.reply_to_message;
47
+ if (!reply) return { ok: false, reason: 'reply to the message you want to rewind to, then send /rewind' };
48
+ if (!rewindSafe) {
49
+ return { ok: false, reason: "rewind isn't available in a shared group chat — turn on per-topic isolation (isolateTopics) so a rewind only affects one topic, not everyone" };
50
+ }
51
+ const allowed = !!isOperatorIdentity || (accessMode === 'paired' && !!paired);
52
+ if (!allowed) {
53
+ return { ok: false, reason: accessMode === 'paired' ? 'only paired members can rewind this chat' : 'only the operator can rewind this chat' };
54
+ }
55
+ const fromId = reply.from?.id;
56
+ const isBotMsg = !!botUsername && reply.from?.username === botUsername;
57
+ const isOwnMsg = fromId != null && msg.from?.id != null && Number(fromId) === Number(msg.from.id);
58
+ if (!isBotMsg && !isOwnMsg) {
59
+ return { ok: false, reason: 'you can only rewind to your own messages or mine' };
60
+ }
61
+ return { ok: true };
62
+ }
63
+
64
+ function previewOf(text) {
65
+ const first = String(text || '').split('\n')[0].trim();
66
+ return first.length > 60 ? `${first.slice(0, 57)}…` : (first || '(no text)');
67
+ }
68
+
69
+ /**
70
+ * @param {object} deps
71
+ * @param {object} deps.pm — ProcessManager (uses pm.get(sessionKey) + proc 'idle')
72
+ * @param {Function} deps.tg — tg(bot, method, params, meta) sender
73
+ * @param {object} deps.bot
74
+ * @param {string} deps.botName
75
+ * @param {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>} deps.executeRewind
76
+ * — the transcript fork (P2). Injected; P1 wires a stub.
77
+ * @param {Function} [deps.logEvent]
78
+ * @param {object} [deps.logger]
79
+ */
80
+ function createRewindHandler({ pm, tg, bot, botName = 'bot', executeRewind, logEvent = () => {}, logger = console } = {}) {
81
+ if (typeof executeRewind !== 'function') throw new TypeError('createRewindHandler: executeRewind required');
82
+
83
+ function ack(chatId, threadId, text) {
84
+ return tg(bot, 'sendMessage', { chat_id: chatId, text, ...(threadId && { message_thread_id: threadId }) },
85
+ { source: 'rewind', botName })
86
+ .catch((e) => logger.error?.(`[${botName}] rewind ack failed: ${e.message}`));
87
+ }
88
+
89
+ async function run(req) {
90
+ let result;
91
+ try {
92
+ result = await executeRewind(req);
93
+ } catch (e) {
94
+ logger.error?.(`[${botName}] rewind execute threw for ${req.sessionKey}: ${e.message}`);
95
+ result = { ok: false, error: e.message };
96
+ }
97
+ if (result && result.ok) {
98
+ const n = result.droppedCount;
99
+ await ack(req.chatId, req.threadId,
100
+ `⏪ Rewound to: «${previewOf(req.target.text)}»` + (n != null ? ` — ${n} message(s) dropped.` : '.') +
101
+ (result.warning ? `\n⚠️ ${result.warning}` : '') +
102
+ `\nNote: anything I already created (files, Sheets, emails) still exists — say the word to reverse it.`);
103
+ logEvent('rewind-done', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
104
+ } else {
105
+ await ack(req.chatId, req.threadId, `↩️ couldn't rewind — ${(result && result.error) || 'unknown error'}`);
106
+ logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id, error: result && result.error });
107
+ }
108
+ }
109
+
110
+ // Defer to turn-end: a rewind kills+resumes the session, so it must never run mid-turn. If a
111
+ // turn is in flight, run on the proc's next 'idle'. If the proc is torn down first — 'close'
112
+ // or 'session-reset' (kill / LRU evict / bridge disconnect / model change), none of which
113
+ // emit 'idle' — tell the operator instead of leaving them hanging after the "queued" ack
114
+ // (Finding D). DEFER_TIMEOUT_MS backstops a proc that somehow emits neither (it sits above
115
+ // the 30-min absolute turn cap, so a legitimately long turn still completes first).
116
+ function schedule(req) {
117
+ const proc = pm && typeof pm.get === 'function' ? pm.get(req.sessionKey) : null;
118
+ if (proc && proc.inFlight) {
119
+ let settled = false;
120
+ const cleanup = () => {
121
+ clearTimeout(timer);
122
+ proc.removeListener('idle', onIdle);
123
+ proc.removeListener('close', onDead);
124
+ proc.removeListener('session-reset', onDead);
125
+ };
126
+ const onIdle = () => { if (settled) return; settled = true; cleanup(); run(req).catch(() => {}); };
127
+ const onDead = () => {
128
+ if (settled) return; settled = true; cleanup();
129
+ ack(req.chatId, req.threadId, "↩️ couldn't rewind — the session ended before the turn finished. Reply /rewind again.");
130
+ logEvent('rewind-deferred-lost', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
131
+ };
132
+ const timer = setTimeout(() => {
133
+ if (settled) return; settled = true; cleanup();
134
+ ack(req.chatId, req.threadId, "↩️ couldn't rewind — timed out waiting for the current turn to finish. Reply /rewind again.");
135
+ logEvent('rewind-deferred-timeout', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
136
+ }, DEFER_TIMEOUT_MS);
137
+ if (timer.unref) timer.unref();
138
+ proc.once('idle', onIdle);
139
+ proc.once('close', onDead);
140
+ proc.once('session-reset', onDead);
141
+ return 'deferred';
142
+ }
143
+ setImmediate(() => { run(req).catch(() => {}); });
144
+ return 'now';
145
+ }
146
+
147
+ /**
148
+ * Dispatcher hook. Returns { consumed }. Consumes any `/rewind` (valid → queued, invalid →
149
+ * the operator is told why) so it never starts a normal turn.
150
+ */
151
+ async function tryConsume({ sessionKey, chatId, threadId = null, msg, cleanText, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode }) {
152
+ if (!isRewindCommand(cleanText)) return { consumed: false };
153
+ const gate = gateRewindRequest({ msg, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode });
154
+ if (!gate.ok) {
155
+ await ack(chatId, threadId, `↩️ ${gate.reason}`);
156
+ return { consumed: true };
157
+ }
158
+ const reply = msg.reply_to_message;
159
+ const req = {
160
+ sessionKey, chatId, threadId,
161
+ target: { msg_id: reply.message_id, text: reply.text || reply.caption || '', ts: reply.date },
162
+ };
163
+ const when = schedule(req);
164
+ await ack(chatId, threadId, when === 'deferred'
165
+ ? '⏳ Rewind queued — I’ll run it the moment the current turn finishes.'
166
+ : '⏪ Rewinding…');
167
+ logEvent('rewind-requested', { session_key: sessionKey, target_msg_id: req.target.msg_id, when });
168
+ return { consumed: true };
169
+ }
170
+
171
+ return { tryConsume };
172
+ }
173
+
174
+ module.exports = { isRewindCommand, gateRewindRequest, previewOf, createRewindHandler };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.30",
3
+ "version": "0.12.0-rc.31",
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
@@ -58,6 +58,8 @@ const { createBuildSdkOptions } = require('./lib/sdk/build-options');
58
58
  const { createSdkCallbacks } = require('./lib/sdk/callbacks');
59
59
  const { createQuestionStore } = require('./lib/questions/store');
60
60
  const { createQuestionHandlers } = require('./lib/handlers/questions');
61
+ const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
62
+ const { createRewindExecutor } = require('./lib/rewind/execute');
61
63
  const { createTranscribeVoiceAttachments } = require('./lib/handlers/voice');
62
64
  const { createDownloadAttachments } = require('./lib/handlers/download');
63
65
  const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
@@ -337,6 +339,9 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
337
339
  db,
338
340
  chatId,
339
341
  threadId: threadId || null,
342
+ // Per-topic sessions must only preload their OWN thread — else a message
343
+ // in one topic gets fed other topics' history (2026-06-09 cross-topic bleed).
344
+ isolateTopics: chatConfig?.isolateTopics === true,
340
345
  excludeMsgId: msg.message_id,
341
346
  logger: console,
342
347
  });
@@ -649,6 +654,7 @@ let handleApprovalCallback = null;
649
654
  // 0.12 interactive questions — assigned in main() once db.raw + pm exist; the
650
655
  // createSdkCallbacks onQuestionAsked closure + the callback router read it late.
651
656
  let questionHandlers = null;
657
+ let rewindHandler = null; // 0.13 /rewind (P1); late-bound, assigned in main() after pm exists
652
658
  let resolveApprovalWaiter = null;
653
659
  let startApprovalSweeper = null;
654
660
  let cancelAllWaiters = null;
@@ -1862,6 +1868,37 @@ function createBot(token) {
1862
1868
  const threadId = msg.message_thread_id?.toString();
1863
1869
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1864
1870
 
1871
+ // 0.13 /rewind: the operator replies `/rewind` to a message to rewind the conversation to
1872
+ // before it. A command, so it bypasses the mention gate. Gated in the handler: rewind-safe
1873
+ // chat (DM or isolateTopics group) → operator/admin identity (or any paired user only when
1874
+ // rewindAccess='paired') → message-ownership. P1 detects/gates/defers; P2 runs the fork.
1875
+ // Defensive: never drop a message.
1876
+ if (rewindHandler && isRewindCommand(cleanText)) {
1877
+ try {
1878
+ // Operator identity: explicit operatorUserId, else the admin user — a PRIVATE adminChatId
1879
+ // equals that user's Telegram id. A group adminChatId (negative) is not a user id → never
1880
+ // matches a positive sender id → default-deny (no valid operator → /rewind refused).
1881
+ const opId = config.bot?.operatorUserId;
1882
+ const adminChatId = config.bot?.adminChatId;
1883
+ const operatorUid = opId != null ? Number(opId) : (adminChatId != null ? Number(adminChatId) : null);
1884
+ const isOperatorIdentity = operatorUid != null && msg.from?.id != null && Number(msg.from.id) === operatorUid;
1885
+ const paired = pairings && msg.from?.id
1886
+ ? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
1887
+ : false;
1888
+ const accessMode = chatConfig?.rewindAccess === 'paired' ? 'paired' : 'operator';
1889
+ // Rewind-safe: a DM (single session) OR a per-topic-isolated group. A shared group session
1890
+ // would blast every topic/user on rewind — refuse.
1891
+ const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
1892
+ const r = await rewindHandler.tryConsume({ sessionKey, chatId, threadId, msg, cleanText, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode });
1893
+ if (r.consumed) return;
1894
+ } catch (err) {
1895
+ // The text IS a recognized /rewind command — on an internal error, consume it anyway
1896
+ // (Finding I). Falling through would send "/rewind" to claude as a normal prompt.
1897
+ console.error(`[${BOT_NAME}] rewind tryConsume failed: ${err?.message || err}`);
1898
+ return;
1899
+ }
1900
+ }
1901
+
1865
1902
  // 0.12 interactive questions: the owner's free-text "Other" answer arrives
1866
1903
  // WITHOUT an @mention, so in a mention-gated group shouldHandle would reject
1867
1904
  // it and the "Other" flow would silently dead-end. Let the claimed owner's
@@ -2295,6 +2332,15 @@ async function main() {
2295
2332
  }
2296
2333
  } catch (e) { console.error(`[${BOT_NAME}] question sweep: ${e.message}`); }
2297
2334
  }, 30_000).unref?.();
2335
+
2336
+ // 0.13 /rewind: detect + operator/ownership gate + turn-end defer + confirm (P1), backed by
2337
+ // the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
2338
+ // orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
2339
+ // See docs/0.13-rewind-design.md.
2340
+ const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
2341
+ rewindHandler = createRewindHandler({
2342
+ pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
2343
+ });
2298
2344
  buildSdkOptions = createBuildSdkOptions({
2299
2345
  config,
2300
2346
  botName: BOT_NAME,