@foxden-app/foxclaw 0.5.9 → 0.5.11

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.
@@ -35,6 +35,7 @@ const CODEX_AUTH_PROACTIVE_REFRESH_INTERVAL_MS = 60 * 60_000;
35
35
  const CODEX_AUTH_PROACTIVE_REFRESH_INITIAL_DELAY_MS = 5 * 60_000;
36
36
  const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
37
37
  const SELF_UPDATE_STATUS_POLL_MS = 1000;
38
+ const ATTACHMENT_BATCH_MERGE_WINDOW_MS = 120_000;
38
39
  const PLAN_IMPLEMENTATION_CODING_MESSAGE = 'Implement the plan.';
39
40
  const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = 'A previous agent produced the plan below to accomplish the user\'s task. Implement the plan in a fresh context. Treat the plan as the source of user intent, re-read files as needed, and carry the work through implementation and verification.';
40
41
  const PINNED_HELP_COMMANDS = [
@@ -105,11 +106,9 @@ export class BridgeSessionCore {
105
106
  activeTurns = new Map();
106
107
  activeTurnsByTurnId = new Map();
107
108
  observedThreadWatchers = new Map();
108
- queuedPrompts = new Map();
109
109
  pendingTurnErrors = new Map();
110
110
  pendingUserInputs = new Map();
111
111
  pendingMcpElicitations = new Map();
112
- pendingPlanImplementations = new Map();
113
112
  pendingLoginsByScope = new Map();
114
113
  pendingLoginScopesById = new Map();
115
114
  pendingAuthAddsByLoginId = new Map();
@@ -195,7 +194,6 @@ export class BridgeSessionCore {
195
194
  });
196
195
  this.app.on('disconnected', () => {
197
196
  this.attachedThreads.clear();
198
- this.queuedPrompts.clear();
199
197
  this.threadTokenUsageAlerts.clear();
200
198
  this.clearObservedThreadWatchers();
201
199
  void this.abandonActiveTurns().catch((error) => {
@@ -204,8 +202,13 @@ export class BridgeSessionCore {
204
202
  this.updateStatus();
205
203
  });
206
204
  await this.app.start();
205
+ this.store.requeueInterruptedQueuedTurnInputs();
207
206
  await this.restorePendingUserInputs();
207
+ await this.restoreGuidedPlanSessions();
208
208
  await this.cleanupStaleTurnPreviews();
209
+ void this.recoverQueuedTurns().catch((error) => {
210
+ this.logger.warn('codex.queued_turn_recovery_failed', { error: toErrorMeta(error) });
211
+ });
209
212
  void this.refreshCodexLocalUsageIfNeeded().catch((error) => {
210
213
  this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
211
214
  });
@@ -226,11 +229,9 @@ export class BridgeSessionCore {
226
229
  await this.startTelegramPolling();
227
230
  }
228
231
  async stop() {
229
- this.queuedPrompts.clear();
230
232
  this.pendingTurnErrors.clear();
231
233
  this.pendingUserInputs.clear();
232
234
  this.pendingMcpElicitations.clear();
233
- this.pendingPlanImplementations.clear();
234
235
  this.pendingLoginsByScope.clear();
235
236
  this.pendingLoginScopesById.clear();
236
237
  this.pendingAuthAddsByLoginId.clear();
@@ -269,6 +270,7 @@ export class BridgeSessionCore {
269
270
  currentBindings: this.store.countBindings(),
270
271
  pendingApprovals: this.store.countPendingApprovals(),
271
272
  pendingUserInputs: this.store.countPendingUserInputs(),
273
+ queuedTurns: this.store.countQueuedTurnInputs(),
272
274
  activeTurns: this.activeTurns.size,
273
275
  lastError: this.lastError,
274
276
  updatedAt: new Date().toISOString(),
@@ -351,6 +353,13 @@ export class BridgeSessionCore {
351
353
  await this.handleCommand(event, locale, decision.command.name, decision.command.args);
352
354
  return;
353
355
  }
356
+ if (event.attachments.length > 0) {
357
+ await this.handleTelegramAttachmentBatch(event, locale, decision.text);
358
+ return;
359
+ }
360
+ if (await this.consumePendingAttachmentBatchWithText(event, locale, decision.text)) {
361
+ return;
362
+ }
354
363
  if (this.findActiveTurn(scopeId)) {
355
364
  await this.handleActiveTurnInboundMessage(event, locale, decision.text);
356
365
  return;
@@ -408,6 +417,7 @@ export class BridgeSessionCore {
408
417
  t(locale, 'status_sync_on_turn_complete', { value: t(locale, this.config.codexAppSyncOnTurnComplete ? 'yes' : 'no') }),
409
418
  t(locale, 'status_pending_approvals', { value: this.store.countPendingApprovals() }),
410
419
  t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
420
+ t(locale, 'status_queued_turns', { value: this.store.countQueuedTurnInputs(scopeId) }),
411
421
  t(locale, 'status_active_turns', { value: this.activeTurns.size }),
412
422
  ];
413
423
  if (serviceStatus) {
@@ -582,7 +592,7 @@ export class BridgeSessionCore {
582
592
  await this.sendMessage(scopeId, t(locale, 'thread_is_archived_use_unarchive'));
583
593
  return;
584
594
  }
585
- this.queuedPrompts.delete(scopeId);
595
+ this.store.cancelQueuedTurnInputs(scopeId);
586
596
  await this.stopWatchingScopeThread(scopeId, thread.threadId);
587
597
  let binding;
588
598
  try {
@@ -1055,7 +1065,7 @@ export class BridgeSessionCore {
1055
1065
  await this.handleAuthListViewCallback(event, authClearSearchMatch[1], 'clear_search', locale);
1056
1066
  return;
1057
1067
  }
1058
- const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1068
+ const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|safe_sync|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1059
1069
  if (authActionMatch) {
1060
1070
  await this.handleAuthPanelActionCallback(event, authActionMatch[1], authActionMatch[2], locale);
1061
1071
  return;
@@ -1075,6 +1085,11 @@ export class BridgeSessionCore {
1075
1085
  await this.handlePlanImplementationCallback(event, planImplMatch[1], planImplMatch[2], locale);
1076
1086
  return;
1077
1087
  }
1088
+ const attachmentBatchMatch = /^attach:([a-f0-9]+):(analyze|clear)$/.exec(event.data);
1089
+ if (attachmentBatchMatch) {
1090
+ await this.handleAttachmentBatchCallback(event, attachmentBatchMatch[1], attachmentBatchMatch[2], locale);
1091
+ return;
1092
+ }
1078
1093
  const mcpElicitationMatch = /^mcpel:([a-f0-9]+):(accept|decline|cancel)$/.exec(event.data);
1079
1094
  if (mcpElicitationMatch) {
1080
1095
  await this.handleMcpElicitationCallback(event, mcpElicitationMatch[1], mcpElicitationMatch[2], locale);
@@ -1782,6 +1797,7 @@ export class BridgeSessionCore {
1782
1797
  await this.completeTurn(active);
1783
1798
  await this.cleanupObservedTransientMessages(active);
1784
1799
  await this.finalizeUserInputsForTurn(active, 'resolved');
1800
+ this.markQueuedTurnCompleted(active);
1785
1801
  }
1786
1802
  finally {
1787
1803
  if (active.isObserved) {
@@ -2076,7 +2092,7 @@ export class BridgeSessionCore {
2076
2092
  return turn?.status === 'inProgress' && snapshot.activeFlags.includes('waitingOnUserInput');
2077
2093
  }
2078
2094
  async maybeSendPlanImplementationPrompt(active) {
2079
- if (active.interruptRequested || this.queuedPrompts.has(active.scopeId)) {
2095
+ if (active.interruptRequested || this.store.countQueuedTurnInputs(active.scopeId) > 0) {
2080
2096
  return false;
2081
2097
  }
2082
2098
  if (this.hasPendingUserInputForTurn(active.scopeId, active.turnId)) {
@@ -2092,8 +2108,8 @@ export class BridgeSessionCore {
2092
2108
  return false;
2093
2109
  }
2094
2110
  const binding = this.store.getBinding(active.scopeId);
2095
- const record = {
2096
- localId: crypto.randomBytes(8).toString('hex'),
2111
+ const session = {
2112
+ sessionId: crypto.randomBytes(8).toString('hex'),
2097
2113
  scopeId: active.scopeId,
2098
2114
  chatId: active.chatId,
2099
2115
  chatType: active.chatType,
@@ -2103,31 +2119,68 @@ export class BridgeSessionCore {
2103
2119
  cwd: binding?.cwd ?? null,
2104
2120
  planMarkdown,
2105
2121
  messageId: null,
2122
+ state: 'awaiting_confirmation',
2106
2123
  createdAt: Date.now(),
2124
+ updatedAt: Date.now(),
2125
+ resolvedAt: null,
2107
2126
  };
2108
- this.pendingPlanImplementations.set(record.localId, record);
2127
+ this.store.saveGuidedPlanSession(session);
2128
+ const record = this.pendingPlanImplementationFromSession(session);
2109
2129
  const locale = this.localeForChat(active.scopeId);
2110
2130
  const messageId = await this.sendMessage(active.scopeId, renderPlanImplementationPrompt(locale, record), planImplementationKeyboard(locale, record.localId));
2111
- record.messageId = messageId;
2131
+ this.store.updateGuidedPlanSessionMessage(session.sessionId, messageId);
2112
2132
  return true;
2113
2133
  }
2114
2134
  findPendingPlanImplementation(scopeId, turnId) {
2115
- for (const record of this.pendingPlanImplementations.values()) {
2116
- if (record.scopeId !== scopeId) {
2117
- continue;
2118
- }
2119
- if (turnId !== undefined && record.turnId !== turnId) {
2120
- continue;
2121
- }
2122
- return record;
2123
- }
2124
- return null;
2135
+ const session = this.store.findOpenGuidedPlanSession(scopeId, turnId);
2136
+ return session ? this.pendingPlanImplementationFromSession(session) : null;
2125
2137
  }
2126
2138
  clearPlanImplementationPromptsForScope(scopeId) {
2127
- for (const [localId, record] of this.pendingPlanImplementations.entries()) {
2128
- if (record.scopeId === scopeId) {
2129
- this.pendingPlanImplementations.delete(localId);
2139
+ const record = this.store.findOpenGuidedPlanSession(scopeId);
2140
+ if (record) {
2141
+ this.store.updateGuidedPlanSessionState(record.sessionId, 'cancelled');
2142
+ }
2143
+ }
2144
+ pendingPlanImplementationFromSession(session) {
2145
+ return {
2146
+ localId: session.sessionId,
2147
+ scopeId: session.scopeId,
2148
+ chatId: session.chatId,
2149
+ chatType: session.chatType,
2150
+ topicId: session.topicId,
2151
+ threadId: session.threadId,
2152
+ turnId: session.turnId,
2153
+ cwd: session.cwd,
2154
+ planMarkdown: session.planMarkdown,
2155
+ messageId: session.messageId,
2156
+ createdAt: session.createdAt,
2157
+ };
2158
+ }
2159
+ async restoreGuidedPlanSessions() {
2160
+ for (const session of this.store.listOpenGuidedPlanSessions()) {
2161
+ if (!this.messaging.canSendToScope(session.scopeId)) {
2162
+ continue;
2130
2163
  }
2164
+ const locale = this.localeForChat(session.scopeId);
2165
+ const record = this.pendingPlanImplementationFromSession(session);
2166
+ if (session.messageId !== null) {
2167
+ try {
2168
+ await this.editMessage(session.scopeId, session.messageId, renderPlanImplementationPrompt(locale, record), planImplementationKeyboard(locale, record.localId));
2169
+ continue;
2170
+ }
2171
+ catch (error) {
2172
+ if (!isTelegramMessageGone(error)) {
2173
+ this.logger.warn('telegram.plan_impl_restore_edit_failed', {
2174
+ sessionId: session.sessionId,
2175
+ scopeId: session.scopeId,
2176
+ messageId: session.messageId,
2177
+ error: toErrorMeta(error),
2178
+ });
2179
+ }
2180
+ }
2181
+ }
2182
+ const messageId = await this.sendMessage(session.scopeId, renderPlanImplementationPrompt(locale, record), planImplementationKeyboard(locale, record.localId));
2183
+ this.store.updateGuidedPlanSessionMessage(session.sessionId, messageId);
2131
2184
  }
2132
2185
  }
2133
2186
  hasPendingUserInputForTurn(scopeId, turnId) {
@@ -2180,17 +2233,18 @@ export class BridgeSessionCore {
2180
2233
  }
2181
2234
  }
2182
2235
  async handlePlanImplementationCallback(event, localId, action, locale) {
2183
- const record = this.pendingPlanImplementations.get(localId);
2184
- if (!record) {
2236
+ const session = this.store.getGuidedPlanSession(localId);
2237
+ if (!session || session.state !== 'awaiting_confirmation') {
2185
2238
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'plan_impl_expired'));
2186
2239
  return;
2187
2240
  }
2241
+ const record = this.pendingPlanImplementationFromSession(session);
2188
2242
  if (record.scopeId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
2189
2243
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'plan_impl_mismatch'));
2190
2244
  return;
2191
2245
  }
2192
2246
  if (action === 'stay') {
2193
- this.pendingPlanImplementations.delete(localId);
2247
+ this.store.updateGuidedPlanSessionState(localId, 'cancelled');
2194
2248
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
2195
2249
  if (record.messageId !== null) {
2196
2250
  await this.editMessage(record.scopeId, record.messageId, t(locale, 'plan_impl_staying'), []);
@@ -2201,9 +2255,9 @@ export class BridgeSessionCore {
2201
2255
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'wait_current_turn'));
2202
2256
  return;
2203
2257
  }
2204
- this.pendingPlanImplementations.delete(localId);
2205
2258
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
2206
2259
  const turn = await this.startPlanImplementationTurn(record, action === 'fresh');
2260
+ this.store.updateGuidedPlanSessionState(localId, 'completed');
2207
2261
  if (record.messageId !== null) {
2208
2262
  await this.editMessage(record.scopeId, record.messageId, t(locale, action === 'fresh' ? 'plan_impl_started_fresh' : 'plan_impl_started', {
2209
2263
  threadId: turn.threadId,
@@ -2218,17 +2272,18 @@ export class BridgeSessionCore {
2218
2272
  await this.sendMessage(scopeId, t(locale, 'usage_planimpl'));
2219
2273
  return;
2220
2274
  }
2221
- const record = this.pendingPlanImplementations.get(localId);
2222
- if (!record) {
2275
+ const session = this.store.getGuidedPlanSession(localId);
2276
+ if (!session || session.state !== 'awaiting_confirmation') {
2223
2277
  await this.sendMessage(scopeId, t(locale, 'plan_impl_expired'));
2224
2278
  return;
2225
2279
  }
2280
+ const record = this.pendingPlanImplementationFromSession(session);
2226
2281
  if (record.scopeId !== scopeId) {
2227
2282
  await this.sendMessage(scopeId, t(locale, 'plan_impl_mismatch'));
2228
2283
  return;
2229
2284
  }
2230
2285
  if (action === 'stay') {
2231
- this.pendingPlanImplementations.delete(localId);
2286
+ this.store.updateGuidedPlanSessionState(localId, 'cancelled');
2232
2287
  if (record.messageId !== null) {
2233
2288
  await this.editMessage(record.scopeId, record.messageId, t(locale, 'plan_impl_staying'), []);
2234
2289
  }
@@ -2241,8 +2296,8 @@ export class BridgeSessionCore {
2241
2296
  await this.sendMessage(scopeId, t(locale, 'wait_current_turn'));
2242
2297
  return;
2243
2298
  }
2244
- this.pendingPlanImplementations.delete(localId);
2245
2299
  const turn = await this.startPlanImplementationTurn(record, action === 'fresh');
2300
+ this.store.updateGuidedPlanSessionState(localId, 'completed');
2246
2301
  const message = t(locale, action === 'fresh' ? 'plan_impl_started_fresh' : 'plan_impl_started', {
2247
2302
  threadId: turn.threadId,
2248
2303
  turnId: turn.turnId,
@@ -2497,6 +2552,22 @@ export class BridgeSessionCore {
2497
2552
  }
2498
2553
  return input;
2499
2554
  }
2555
+ buildTurnInputFromStagedAttachments(text, attachments) {
2556
+ const input = [{
2557
+ type: 'text',
2558
+ text: buildAttachmentPrompt(text, attachments),
2559
+ text_elements: [],
2560
+ }];
2561
+ for (const attachment of attachments) {
2562
+ if (!attachment.nativeImage)
2563
+ continue;
2564
+ input.push({
2565
+ type: 'localImage',
2566
+ path: attachment.localPath,
2567
+ });
2568
+ }
2569
+ return input;
2570
+ }
2500
2571
  async resolveServiceTierForTurn(scopeId, settings) {
2501
2572
  if (!settings) {
2502
2573
  return undefined;
@@ -2582,8 +2653,174 @@ export class BridgeSessionCore {
2582
2653
  }
2583
2654
  return staged;
2584
2655
  }
2585
- async registerActiveTurn(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, authRetry = null, collaborationMode = DEFAULT_COLLABORATION_MODE) {
2586
- const active = this.createActiveTurnState(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, false, collaborationMode);
2656
+ async handleTelegramAttachmentBatch(event, locale, text) {
2657
+ const active = this.findActiveTurn(event.scopeId);
2658
+ if (active?.isObserved) {
2659
+ await this.sendMessage(event.scopeId, t(locale, 'watch_read_only_active'));
2660
+ return;
2661
+ }
2662
+ const existingBinding = this.store.getBinding(event.scopeId);
2663
+ const binding = active
2664
+ ? {
2665
+ threadId: active.threadId,
2666
+ cwd: existingBinding?.cwd ?? this.config.defaultCwd,
2667
+ }
2668
+ : existingBinding
2669
+ ? await this.ensureThreadReady(event.scopeId, existingBinding)
2670
+ : await this.createBinding(event.scopeId, null);
2671
+ const cwd = binding.cwd ?? this.config.defaultCwd;
2672
+ const stagedAttachments = await this.stageAttachments(cwd, binding.threadId, event.attachments, locale);
2673
+ const mediaGroupId = event.mediaGroupId ?? null;
2674
+ const now = Date.now();
2675
+ const existingBatch = mediaGroupId
2676
+ ? this.store.findPendingAttachmentBatchByMediaGroup(event.scopeId, mediaGroupId)
2677
+ : findReusableStandaloneAttachmentBatch(this.store.getLatestPendingAttachmentBatch(event.scopeId), now);
2678
+ const existingAttachments = existingBatch ? parseStagedTelegramAttachments(existingBatch.attachmentsJson) : [];
2679
+ const record = {
2680
+ batchId: existingBatch?.batchId ?? crypto.randomBytes(8).toString('hex'),
2681
+ scopeId: event.scopeId,
2682
+ chatId: event.chatId,
2683
+ chatType: event.chatType,
2684
+ topicId: event.topicId,
2685
+ threadId: binding.threadId,
2686
+ cwd,
2687
+ mediaGroupId,
2688
+ attachmentsJson: JSON.stringify([...existingAttachments, ...stagedAttachments]),
2689
+ caption: mergeAttachmentBatchCaption(existingBatch?.caption ?? '', text.trim()),
2690
+ messageId: existingBatch?.messageId ?? null,
2691
+ status: 'pending',
2692
+ createdAt: existingBatch?.createdAt ?? now,
2693
+ updatedAt: now,
2694
+ resolvedAt: null,
2695
+ };
2696
+ this.store.savePendingAttachmentBatch(record);
2697
+ await this.renderAttachmentBatchCard(record, locale);
2698
+ }
2699
+ async consumePendingAttachmentBatchWithText(event, locale, text) {
2700
+ const prompt = text.trim();
2701
+ if (!prompt) {
2702
+ return false;
2703
+ }
2704
+ const batch = this.store.getLatestPendingAttachmentBatch(event.scopeId);
2705
+ if (!batch) {
2706
+ return false;
2707
+ }
2708
+ await this.consumePendingAttachmentBatch(batch, locale, prompt, event.messageId);
2709
+ return true;
2710
+ }
2711
+ async handleAttachmentBatchCallback(event, batchId, action, locale) {
2712
+ const batch = this.store.getPendingAttachmentBatch(batchId);
2713
+ if (!batch || batch.status !== 'pending') {
2714
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'attachment_batch_missing'));
2715
+ return;
2716
+ }
2717
+ if (batch.scopeId !== event.scopeId || (batch.messageId !== null && batch.messageId !== event.messageId)) {
2718
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'attachment_batch_mismatch'));
2719
+ return;
2720
+ }
2721
+ if (action === 'clear') {
2722
+ this.store.resolvePendingAttachmentBatch(batch.batchId, 'cleared');
2723
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
2724
+ if (batch.messageId !== null) {
2725
+ await this.editMessage(batch.scopeId, batch.messageId, t(locale, 'attachment_batch_cleared'), []);
2726
+ }
2727
+ return;
2728
+ }
2729
+ const prompt = batch.caption.trim() || t(locale, 'attachment_batch_default_prompt');
2730
+ await this.consumePendingAttachmentBatch(batch, locale, prompt, batch.messageId);
2731
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
2732
+ }
2733
+ async consumePendingAttachmentBatch(batch, locale, prompt, sourceMessageId) {
2734
+ const active = this.findActiveTurn(batch.scopeId);
2735
+ if (active?.isObserved) {
2736
+ await this.sendMessage(batch.scopeId, t(locale, 'watch_read_only_active'));
2737
+ return;
2738
+ }
2739
+ const attachments = parseStagedTelegramAttachments(batch.attachmentsJson);
2740
+ const input = this.buildTurnInputFromStagedAttachments(prompt, attachments);
2741
+ if (active) {
2742
+ const queueId = this.enqueuePreparedTurnInput({
2743
+ scopeId: batch.scopeId,
2744
+ chatId: batch.chatId,
2745
+ chatType: batch.chatType,
2746
+ topicId: batch.topicId,
2747
+ threadId: active.threadId,
2748
+ input,
2749
+ sourceSummary: summarizeStagedAttachmentInput(prompt, attachments),
2750
+ messageId: sourceMessageId,
2751
+ });
2752
+ this.store.resolvePendingAttachmentBatch(batch.batchId, 'consumed');
2753
+ if (batch.messageId !== null) {
2754
+ await this.editMessage(batch.scopeId, batch.messageId, t(locale, 'attachment_batch_queued', {
2755
+ id: shortId(queueId),
2756
+ position: this.store.countQueuedTurnInputs(batch.scopeId),
2757
+ }), []);
2758
+ }
2759
+ else {
2760
+ await this.sendMessage(batch.scopeId, t(locale, 'attachment_batch_queued', {
2761
+ id: shortId(queueId),
2762
+ position: this.store.countQueuedTurnInputs(batch.scopeId),
2763
+ }));
2764
+ }
2765
+ return;
2766
+ }
2767
+ this.store.resolvePendingAttachmentBatch(batch.batchId, 'consumed');
2768
+ if (batch.messageId !== null) {
2769
+ await this.editMessage(batch.scopeId, batch.messageId, t(locale, 'attachment_batch_consumed'), []);
2770
+ }
2771
+ await this.startTurnFromPreparedAttachmentBatch(batch, input);
2772
+ }
2773
+ async startTurnFromPreparedAttachmentBatch(batch, input) {
2774
+ this.clearPlanImplementationPromptsForScope(batch.scopeId);
2775
+ await this.stopWatchingScopeThread(batch.scopeId);
2776
+ const existingBinding = this.store.getBinding(batch.scopeId);
2777
+ const binding = await this.ensureThreadReady(batch.scopeId, {
2778
+ chatId: batch.scopeId,
2779
+ threadId: batch.threadId,
2780
+ cwd: existingBinding?.cwd ?? batch.cwd,
2781
+ updatedAt: Date.now(),
2782
+ });
2783
+ this.store.setBinding(batch.scopeId, binding.threadId, binding.cwd);
2784
+ await this.sendTyping(batch.scopeId);
2785
+ const turnState = await this.startTurnWithRecovery(batch.scopeId, binding, input);
2786
+ if (turnState.collaborationMode === 'plan') {
2787
+ this.store.setChatCollaborationMode(batch.scopeId, DEFAULT_COLLABORATION_MODE);
2788
+ }
2789
+ await this.registerActiveTurn(batch.scopeId, batch.chatId, batch.chatType, batch.topicId, turnState.threadId, turnState.turnId, 0, {
2790
+ input,
2791
+ threadId: turnState.threadId,
2792
+ cwd: this.store.getBinding(batch.scopeId)?.cwd ?? binding.cwd ?? this.config.defaultCwd,
2793
+ chatId: batch.chatId,
2794
+ chatType: batch.chatType,
2795
+ topicId: batch.topicId,
2796
+ collaborationMode: turnState.collaborationMode,
2797
+ failedAuthTargets: new Set(),
2798
+ }, turnState.collaborationMode);
2799
+ }
2800
+ async renderAttachmentBatchCard(record, locale) {
2801
+ const text = renderAttachmentBatchMessage(locale, record);
2802
+ const keyboard = attachmentBatchKeyboard(locale, record.batchId);
2803
+ if (record.messageId !== null) {
2804
+ try {
2805
+ await this.editMessage(record.scopeId, record.messageId, text, keyboard);
2806
+ return;
2807
+ }
2808
+ catch (error) {
2809
+ if (!isTelegramMessageGone(error)) {
2810
+ this.logger.warn('telegram.attachment_batch_edit_failed', {
2811
+ batchId: record.batchId,
2812
+ scopeId: record.scopeId,
2813
+ messageId: record.messageId,
2814
+ error: toErrorMeta(error),
2815
+ });
2816
+ }
2817
+ }
2818
+ }
2819
+ const messageId = await this.sendMessage(record.scopeId, text, keyboard);
2820
+ this.store.updatePendingAttachmentBatchMessage(record.batchId, messageId);
2821
+ }
2822
+ async registerActiveTurn(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, authRetry = null, collaborationMode = DEFAULT_COLLABORATION_MODE, queuedInputId = null) {
2823
+ const active = this.createActiveTurnState(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, false, collaborationMode, queuedInputId);
2587
2824
  active.authRetry = authRetry;
2588
2825
  this.setActiveTurn(scopeId, turnId, active);
2589
2826
  const pendingError = this.pendingTurnErrors.get(turnId);
@@ -2607,7 +2844,7 @@ export class BridgeSessionCore {
2607
2844
  this.logger.warn('telegram.preview_keyboard_attach_failed', { error: String(error), turnId });
2608
2845
  }
2609
2846
  }
2610
- createActiveTurnState(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, isObserved = false, collaborationMode = DEFAULT_COLLABORATION_MODE) {
2847
+ createActiveTurnState(scopeId, chatId, chatType, topicId, threadId, turnId, previewMessageId, isObserved = false, collaborationMode = DEFAULT_COLLABORATION_MODE, queuedInputId = null) {
2611
2848
  let resolver = () => { };
2612
2849
  const completion = new Promise((resolve) => {
2613
2850
  resolver = resolve;
@@ -2621,6 +2858,7 @@ export class BridgeSessionCore {
2621
2858
  isObserved,
2622
2859
  threadId,
2623
2860
  turnId,
2861
+ queuedInputId,
2624
2862
  previewMessageId,
2625
2863
  previewActive: previewMessageId > 0,
2626
2864
  draftId: null,
@@ -2718,7 +2956,7 @@ export class BridgeSessionCore {
2718
2956
  authDisplayBotLabel() {
2719
2957
  if (!this.config.tgScopeBotId)
2720
2958
  return null;
2721
- return this.botUsername ? `@${this.botUsername}` : this.config.tgScopeBotId;
2959
+ return this.botUsername ? `@${this.botUsername} (${this.config.tgScopeBotId})` : this.config.tgScopeBotId;
2722
2960
  }
2723
2961
  ownsScope(scopeId) {
2724
2962
  if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
@@ -2889,6 +3127,7 @@ export class BridgeSessionCore {
2889
3127
  await this.cleanupObservedTransientMessages(active);
2890
3128
  await this.finalizeUserInputsForTurn(active, active.interruptRequested ? 'interrupted' : 'resolved');
2891
3129
  await this.maybeSendPlanImplementationPrompt(active);
3130
+ this.markQueuedTurnCompleted(active);
2892
3131
  if (this.config.codexAppSyncOnTurnComplete) {
2893
3132
  const revealError = await this.tryRevealThread(active.scopeId, active.threadId, 'turn-complete');
2894
3133
  if (revealError) {
@@ -3595,7 +3834,7 @@ export class BridgeSessionCore {
3595
3834
  await this.sendMessage(scopeId, t(locale, 'usage_takeover'));
3596
3835
  return;
3597
3836
  }
3598
- this.queuedPrompts.delete(scopeId);
3837
+ this.store.cancelQueuedTurnInputs(scopeId);
3599
3838
  const active = this.findActiveTurn(scopeId);
3600
3839
  if (active) {
3601
3840
  if (active.isObserved) {
@@ -3626,9 +3865,7 @@ export class BridgeSessionCore {
3626
3865
  await this.sendMessage(scopeId, t(locale, 'watch_read_only_active'));
3627
3866
  return;
3628
3867
  }
3629
- const replaced = this.queuedPrompts.has(scopeId);
3630
- this.queuedPrompts.set(scopeId, { event, text: nextPrompt });
3631
- await this.sendMessage(scopeId, t(locale, replaced ? 'queued_prompt_replaced' : 'queued_prompt_set'));
3868
+ await this.queuePromptAfterActiveTurn(event, locale, nextPrompt);
3632
3869
  }
3633
3870
  async handleActiveTurnInboundMessage(event, locale, text) {
3634
3871
  const active = this.findActiveTurn(event.scopeId);
@@ -3663,9 +3900,29 @@ export class BridgeSessionCore {
3663
3900
  }
3664
3901
  }
3665
3902
  async queuePromptAfterActiveTurn(event, locale, text) {
3666
- const replaced = this.queuedPrompts.has(event.scopeId);
3667
- this.queuedPrompts.set(event.scopeId, { event, text });
3668
- await this.sendMessage(event.scopeId, t(locale, replaced ? 'queued_prompt_replaced' : 'queued_prompt_set'));
3903
+ const active = this.findActiveTurn(event.scopeId);
3904
+ if (!active) {
3905
+ await this.startBoundTurnFromEvent(event, locale, text);
3906
+ return;
3907
+ }
3908
+ const input = await this.buildTurnInput({
3909
+ threadId: active.threadId,
3910
+ cwd: this.store.getBinding(event.scopeId)?.cwd ?? this.config.defaultCwd,
3911
+ }, { ...event, text }, locale);
3912
+ const queueId = this.enqueuePreparedTurnInput({
3913
+ scopeId: event.scopeId,
3914
+ chatId: event.chatId,
3915
+ chatType: event.chatType,
3916
+ topicId: event.topicId,
3917
+ threadId: active.threadId,
3918
+ input,
3919
+ sourceSummary: summarizeTelegramInput(text, event.attachments),
3920
+ messageId: event.messageId,
3921
+ });
3922
+ await this.sendMessage(event.scopeId, t(locale, 'queued_prompt_set', {
3923
+ id: shortId(queueId),
3924
+ position: this.store.countQueuedTurnInputs(event.scopeId),
3925
+ }));
3669
3926
  }
3670
3927
  async steerActiveTurn(active, event, locale, text) {
3671
3928
  const binding = {
@@ -3678,16 +3935,87 @@ export class BridgeSessionCore {
3678
3935
  await this.queueTurnRender(active, { forceStatus: true });
3679
3936
  await this.sendMessage(event.scopeId, t(locale, 'steer_sent', { turnId: active.turnId }));
3680
3937
  }
3938
+ enqueuePreparedTurnInput(params) {
3939
+ const now = Date.now();
3940
+ const queueId = crypto.randomBytes(8).toString('hex');
3941
+ this.store.saveQueuedTurnInput({
3942
+ queueId,
3943
+ scopeId: params.scopeId,
3944
+ chatId: params.chatId,
3945
+ chatType: params.chatType,
3946
+ topicId: params.topicId,
3947
+ threadId: params.threadId,
3948
+ inputJson: JSON.stringify(params.input),
3949
+ sourceSummary: params.sourceSummary,
3950
+ messageId: params.messageId,
3951
+ status: 'queued',
3952
+ error: null,
3953
+ createdAt: now,
3954
+ updatedAt: now,
3955
+ resolvedAt: null,
3956
+ });
3957
+ return queueId;
3958
+ }
3959
+ async startBoundTurnFromQueuedInput(record) {
3960
+ const scopeId = record.scopeId;
3961
+ this.clearPlanImplementationPromptsForScope(scopeId);
3962
+ await this.stopWatchingScopeThread(scopeId);
3963
+ const existingBinding = this.store.getBinding(scopeId);
3964
+ const binding = await this.ensureThreadReady(scopeId, {
3965
+ chatId: scopeId,
3966
+ threadId: record.threadId,
3967
+ cwd: existingBinding?.cwd ?? null,
3968
+ updatedAt: Date.now(),
3969
+ });
3970
+ this.store.setBinding(scopeId, binding.threadId, binding.cwd);
3971
+ await this.sendTyping(scopeId);
3972
+ const input = parseStoredTurnInput(record.inputJson);
3973
+ const turnState = await this.startTurnWithRecovery(scopeId, binding, input);
3974
+ if (turnState.collaborationMode === 'plan') {
3975
+ this.store.setChatCollaborationMode(scopeId, DEFAULT_COLLABORATION_MODE);
3976
+ }
3977
+ await this.registerActiveTurn(scopeId, record.chatId, record.chatType, record.topicId, turnState.threadId, turnState.turnId, 0, {
3978
+ input,
3979
+ threadId: turnState.threadId,
3980
+ cwd: this.store.getBinding(scopeId)?.cwd ?? binding.cwd ?? this.config.defaultCwd,
3981
+ chatId: record.chatId,
3982
+ chatType: record.chatType,
3983
+ topicId: record.topicId,
3984
+ collaborationMode: turnState.collaborationMode,
3985
+ failedAuthTargets: new Set(),
3986
+ }, turnState.collaborationMode, record.queueId);
3987
+ }
3681
3988
  async startQueuedPromptIfPresent(scopeId) {
3682
3989
  if (this.findActiveTurn(scopeId)) {
3683
3990
  return;
3684
3991
  }
3685
- const queued = this.queuedPrompts.get(scopeId);
3992
+ const queued = this.store.peekQueuedTurnInput(scopeId);
3686
3993
  if (!queued) {
3687
3994
  return;
3688
3995
  }
3689
- this.queuedPrompts.delete(scopeId);
3690
- await this.startBoundTurnFromEvent(queued.event, this.localeForChat(scopeId, queued.event.languageCode), queued.text);
3996
+ this.store.updateQueuedTurnInputStatus(queued.queueId, 'processing');
3997
+ try {
3998
+ await this.startBoundTurnFromQueuedInput(queued);
3999
+ }
4000
+ catch (error) {
4001
+ this.store.updateQueuedTurnInputStatus(queued.queueId, 'failed', formatUserError(error));
4002
+ throw error;
4003
+ }
4004
+ }
4005
+ async recoverQueuedTurns() {
4006
+ const scopeIds = new Set(this.store.listQueuedTurnInputs().map((record) => record.scopeId));
4007
+ for (const scopeId of scopeIds) {
4008
+ if (!this.messaging.canSendToScope(scopeId)) {
4009
+ continue;
4010
+ }
4011
+ await this.withLock(scopeId, async () => this.startQueuedPromptIfPresent(scopeId));
4012
+ }
4013
+ }
4014
+ markQueuedTurnCompleted(active) {
4015
+ if (!active.queuedInputId) {
4016
+ return;
4017
+ }
4018
+ this.store.updateQueuedTurnInputStatus(active.queuedInputId, 'completed');
3691
4019
  }
3692
4020
  async handleModeCommand(scopeId, locale, args) {
3693
4021
  const raw = args.join(' ').trim();
@@ -3954,16 +4282,28 @@ export class BridgeSessionCore {
3954
4282
  }
3955
4283
  formatSelfUpdateResult(status) {
3956
4284
  const codexUpdateLine = this.formatCodexUpdateResult(status);
4285
+ const releaseNotes = this.formatSelfUpdateReleaseNotes(status);
3957
4286
  if (status.state === 'succeeded') {
3958
4287
  const result = t(status.locale, 'update_succeeded', {
3959
4288
  from: status.fromVersion,
3960
4289
  to: status.toVersion ?? t(status.locale, 'unknown'),
3961
4290
  });
3962
- return codexUpdateLine ? `${result}\n${codexUpdateLine}` : result;
4291
+ return [result, releaseNotes, codexUpdateLine].filter(Boolean).join('\n');
3963
4292
  }
3964
4293
  const result = t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3965
4294
  return codexUpdateLine ? `${result}\n${codexUpdateLine}` : result;
3966
4295
  }
4296
+ formatSelfUpdateReleaseNotes(status) {
4297
+ const notes = status.releaseNotes?.filter(note => note.trim()) ?? [];
4298
+ if (status.state !== 'succeeded' || notes.length === 0) {
4299
+ return null;
4300
+ }
4301
+ return [
4302
+ '',
4303
+ t(status.locale, 'update_changes_title'),
4304
+ ...notes.map(note => `- ${note}`),
4305
+ ].join('\n');
4306
+ }
3967
4307
  formatCodexUpdateResult(status) {
3968
4308
  if (status.codexFromVersion || status.codexToVersion) {
3969
4309
  return t(status.locale, 'update_codex_result', {
@@ -4127,17 +4467,19 @@ export class BridgeSessionCore {
4127
4467
  await this.sendMessage(scopeId, message);
4128
4468
  return;
4129
4469
  }
4130
- if (action === 'push' && args[1]?.toLowerCase() === 'all') {
4470
+ if (action === 'safe' || (action === 'push' && args[1]?.toLowerCase() === 'all')) {
4131
4471
  if (!this.canRunGlobalAuthRefresh()) {
4132
4472
  await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
4133
4473
  return;
4134
4474
  }
4135
- const result = await this.coordinator?.authSyncPushAll?.();
4475
+ const result = await this.runAuthSafeSyncAll();
4136
4476
  if (!result) {
4137
4477
  await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
4138
4478
  return;
4139
4479
  }
4140
- await this.sendMessage(scopeId, t(locale, 'auth_sync_push_done', {
4480
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_safe_done', {
4481
+ localSynced: result.localSynced,
4482
+ localSkipped: result.localSkipped,
4141
4483
  sent: result.sent,
4142
4484
  skipped: result.skipped,
4143
4485
  }));
@@ -4145,6 +4487,16 @@ export class BridgeSessionCore {
4145
4487
  }
4146
4488
  await this.sendMessage(scopeId, t(locale, 'usage_auth_sync'));
4147
4489
  }
4490
+ async runAuthSafeSyncAll() {
4491
+ const safeResult = await this.coordinator?.authSyncSafeAll?.();
4492
+ if (safeResult) {
4493
+ return safeResult;
4494
+ }
4495
+ const pushResult = await this.coordinator?.authSyncPushAll?.();
4496
+ return pushResult
4497
+ ? { localSynced: 0, localSkipped: 0, sent: pushResult.sent, skipped: pushResult.skipped }
4498
+ : null;
4499
+ }
4148
4500
  async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
4149
4501
  if (!this.canRunGlobalAuthRefresh()) {
4150
4502
  await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -4653,6 +5005,37 @@ export class BridgeSessionCore {
4653
5005
  await this.handleLoginDeviceCommand(event.scopeId, locale);
4654
5006
  return;
4655
5007
  }
5008
+ if (action === 'safe_sync') {
5009
+ if (!this.canRunGlobalAuthRefresh()) {
5010
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_sync_push_blocked_active'));
5011
+ return;
5012
+ }
5013
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_sync_safe_starting'));
5014
+ if (record.messageId !== null) {
5015
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_safe_starting'), []);
5016
+ }
5017
+ const result = await this.runAuthSafeSyncAll();
5018
+ if (!result) {
5019
+ if (record.messageId !== null) {
5020
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_disabled'), authChoiceKeyboard(locale, record));
5021
+ }
5022
+ return;
5023
+ }
5024
+ const state = await this.listCodexAuthState();
5025
+ await this.applySharedCodexAuthQuotaSnapshots(state);
5026
+ record.candidates = state.candidates;
5027
+ record.createdAt = Date.now();
5028
+ clampCodexAuthListOffset(record);
5029
+ if (record.messageId !== null) {
5030
+ await this.editMessage(event.scopeId, record.messageId, `${t(locale, 'auth_sync_safe_done', {
5031
+ localSynced: result.localSynced,
5032
+ localSkipped: result.localSkipped,
5033
+ sent: result.sent,
5034
+ skipped: result.skipped,
5035
+ })}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
5036
+ }
5037
+ return;
5038
+ }
4656
5039
  if (action === 'refresh_all') {
4657
5040
  if (!this.canRunGlobalAuthRefresh()) {
4658
5041
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -5707,7 +6090,7 @@ export class BridgeSessionCore {
5707
6090
  await this.createAndBindNewThread(scopeId, locale, cwd, panelMessageId);
5708
6091
  }
5709
6092
  async createAndBindNewThread(scopeId, locale, cwd, panelMessageId) {
5710
- this.queuedPrompts.delete(scopeId);
6093
+ this.store.cancelQueuedTurnInputs(scopeId);
5711
6094
  await this.stopWatchingScopeThread(scopeId);
5712
6095
  const binding = await this.createBinding(scopeId, cwd);
5713
6096
  await this.sendNewThreadStartedMessage(scopeId, locale, binding, cwd);
@@ -6453,6 +6836,9 @@ export class BridgeSessionCore {
6453
6836
  if (active.previewActive) {
6454
6837
  await this.retirePreviewMessage(active.scopeId, active.previewMessageId, t(this.localeForChat(active.scopeId), 'stale_preview_expired'), active.turnId);
6455
6838
  }
6839
+ if (active.queuedInputId) {
6840
+ this.store.updateQueuedTurnInputStatus(active.queuedInputId, 'queued');
6841
+ }
6456
6842
  active.resolver();
6457
6843
  this.deleteActiveTurnRecord(active);
6458
6844
  }
@@ -8066,7 +8452,7 @@ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopy
8066
8452
  if (state.candidates.length === 0) {
8067
8453
  lines.push(t(locale, 'auth_no_candidates'));
8068
8454
  if (includeWeixinCopyPaste) {
8069
- lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), '/login_device', '/auth reload', '/permissions');
8455
+ lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), '/login_device', '/auth sync safe', '/auth reload', '/permissions');
8070
8456
  }
8071
8457
  return lines.join('\n');
8072
8458
  }
@@ -8099,7 +8485,7 @@ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopy
8099
8485
  if (includeWeixinCopyPaste) {
8100
8486
  lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), ...page.visible.map(({ index }) => `/auth use ${index + 1}`), ...page.visible.map(({ candidate, index }) => candidate.disabled
8101
8487
  ? `/auth enable ${index + 1}`
8102
- : `/auth disable ${index + 1}`), '/auth filter all', '/auth filter enabled', '/auth filter attention', '/auth list <keyword>', '/login_device', '/auth reload', '/permissions');
8488
+ : `/auth disable ${index + 1}`), '/auth filter all', '/auth filter enabled', '/auth filter attention', '/auth list <keyword>', '/login_device', '/auth sync safe', '/auth reload', '/permissions');
8103
8489
  }
8104
8490
  return lines.join('\n');
8105
8491
  }
@@ -8137,7 +8523,10 @@ function authChoiceKeyboard(locale, record) {
8137
8523
  { text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
8138
8524
  { text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
8139
8525
  ]);
8140
- rows.push([{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` }]);
8526
+ rows.push([
8527
+ { text: t(locale, 'button_auth_safe_sync'), callback_data: `auth:${record.localId}:safe_sync` },
8528
+ { text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` },
8529
+ ]);
8141
8530
  return rows;
8142
8531
  }
8143
8532
  function formatCodexAuthCandidateDisplayName(name) {
@@ -8743,6 +9132,78 @@ function userInputKeyboard(record) {
8743
9132
  });
8744
9133
  return rows;
8745
9134
  }
9135
+ function parseStoredTurnInput(inputJson) {
9136
+ const value = JSON.parse(inputJson);
9137
+ if (!Array.isArray(value)) {
9138
+ throw new Error('Queued turn input is not an array');
9139
+ }
9140
+ return value;
9141
+ }
9142
+ function parseStagedTelegramAttachments(attachmentsJson) {
9143
+ const value = JSON.parse(attachmentsJson);
9144
+ if (!Array.isArray(value)) {
9145
+ throw new Error('Attachment batch payload is not an array');
9146
+ }
9147
+ return value.map((entry) => {
9148
+ if (!entry || typeof entry !== 'object') {
9149
+ throw new Error('Attachment batch contains an invalid entry');
9150
+ }
9151
+ const attachment = entry;
9152
+ if (typeof attachment.localPath !== 'string' || typeof attachment.relativePath !== 'string') {
9153
+ throw new Error('Attachment batch entry is missing a local path');
9154
+ }
9155
+ return attachment;
9156
+ });
9157
+ }
9158
+ function findReusableStandaloneAttachmentBatch(batch, now) {
9159
+ if (!batch || batch.mediaGroupId !== null) {
9160
+ return null;
9161
+ }
9162
+ return now - batch.updatedAt <= ATTACHMENT_BATCH_MERGE_WINDOW_MS ? batch : null;
9163
+ }
9164
+ function mergeAttachmentBatchCaption(existing, next) {
9165
+ const normalizedNext = next.trim();
9166
+ if (!normalizedNext) {
9167
+ return existing;
9168
+ }
9169
+ const normalizedExisting = existing.trim();
9170
+ if (!normalizedExisting) {
9171
+ return normalizedNext;
9172
+ }
9173
+ return normalizedExisting === normalizedNext ? normalizedExisting : `${normalizedExisting}\n\n${normalizedNext}`;
9174
+ }
9175
+ function summarizeStagedAttachmentInput(text, attachments) {
9176
+ const lines = [text.trim() || '(no text)'];
9177
+ if (attachments.length > 0) {
9178
+ lines.push(`[attachments: ${attachments.map((attachment) => `${attachment.kind}:${attachment.fileName}`).join(', ')}]`);
9179
+ }
9180
+ return lines.join('\n');
9181
+ }
9182
+ function renderAttachmentBatchMessage(locale, record) {
9183
+ const attachments = parseStagedTelegramAttachments(record.attachmentsJson);
9184
+ const lines = [
9185
+ t(locale, 'attachment_batch_staged', { count: attachments.length }),
9186
+ ];
9187
+ if (record.caption.trim()) {
9188
+ lines.push('', t(locale, 'attachment_batch_caption', { value: truncateInline(record.caption.trim(), 600) }));
9189
+ }
9190
+ lines.push('');
9191
+ for (const [index, attachment] of attachments.entries()) {
9192
+ const name = attachment.fileName || attachment.fileUniqueId;
9193
+ const size = attachment.fileSize === null ? '' : `, ${attachment.fileSize} bytes`;
9194
+ lines.push(`${index + 1}. ${attachment.kind}: ${name}${size}`);
9195
+ }
9196
+ return lines.join('\n');
9197
+ }
9198
+ function attachmentBatchKeyboard(locale, batchId) {
9199
+ return [[
9200
+ { text: t(locale, 'attachment_batch_button_analyze'), callback_data: `attach:${batchId}:analyze` },
9201
+ { text: t(locale, 'attachment_batch_button_clear'), callback_data: `attach:${batchId}:clear` },
9202
+ ]];
9203
+ }
9204
+ function shortId(value) {
9205
+ return value.slice(0, 8);
9206
+ }
8746
9207
  function renderPlanImplementationPrompt(locale, record) {
8747
9208
  const lines = [
8748
9209
  t(locale, 'plan_impl_title'),