@foxden-app/foxclaw 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.env.example +18 -1
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +4 -3
  4. package/README_EN.md +4 -3
  5. package/dist/antigravity/account_ui.d.ts +34 -0
  6. package/dist/antigravity/account_ui.js +354 -0
  7. package/dist/antigravity/adapter.js +1 -1
  8. package/dist/antigravity/auth.d.ts +4 -1
  9. package/dist/antigravity/auth.js +17 -5
  10. package/dist/antigravity/controller.d.ts +3 -106
  11. package/dist/antigravity/controller.js +2 -2083
  12. package/dist/antigravity/conversation_ui.d.ts +56 -0
  13. package/dist/antigravity/conversation_ui.js +509 -0
  14. package/dist/antigravity/ui_callbacks.d.ts +6 -0
  15. package/dist/antigravity/ui_callbacks.js +6 -0
  16. package/dist/bridge/unified_bridge.d.ts +114 -0
  17. package/dist/bridge/unified_bridge.js +1343 -0
  18. package/dist/channels/bridge_messaging_router.d.ts +8 -4
  19. package/dist/channels/bridge_messaging_router.js +15 -4
  20. package/dist/channels/telegram/telegram_messaging_port.d.ts +16 -0
  21. package/dist/channels/telegram/telegram_messaging_port.js +22 -2
  22. package/dist/channels/text_actions.d.ts +14 -0
  23. package/dist/channels/text_actions.js +48 -0
  24. package/dist/channels/weixin/weixin_channel_adapter.d.ts +3 -4
  25. package/dist/channels/weixin/weixin_channel_adapter.js +2 -2
  26. package/dist/channels/weixin/weixin_messaging_port.d.ts +16 -1
  27. package/dist/channels/weixin/weixin_messaging_port.js +34 -7
  28. package/dist/codex_app/activity.d.ts +62 -0
  29. package/dist/codex_app/activity.js +418 -0
  30. package/dist/codex_app/adapter.d.ts +2 -2
  31. package/dist/codex_app/adapter.js +55 -25
  32. package/dist/codex_app/backend_ui.d.ts +4 -0
  33. package/dist/codex_app/backend_ui.js +48 -0
  34. package/dist/codex_app/client.js +0 -12
  35. package/dist/config.d.ts +4 -0
  36. package/dist/config.js +41 -5
  37. package/dist/controller/access.d.ts +1 -11
  38. package/dist/controller/access.js +1 -33
  39. package/dist/controller/activity.d.ts +1 -62
  40. package/dist/controller/activity.js +2 -418
  41. package/dist/controller/auth_errors.d.ts +11 -0
  42. package/dist/controller/auth_errors.js +124 -0
  43. package/dist/controller/auth_files.d.ts +13 -0
  44. package/dist/controller/auth_files.js +156 -0
  45. package/dist/controller/auth_presentation.d.ts +77 -0
  46. package/dist/controller/auth_presentation.js +697 -0
  47. package/dist/controller/auth_quota_service.d.ts +27 -0
  48. package/dist/controller/auth_quota_service.js +151 -0
  49. package/dist/controller/controller.d.ts +30 -56
  50. package/dist/controller/controller.js +181 -3497
  51. package/dist/controller/interactions.d.ts +74 -0
  52. package/dist/controller/interactions.js +560 -0
  53. package/dist/controller/local_usage_service.d.ts +19 -0
  54. package/dist/controller/local_usage_service.js +62 -0
  55. package/dist/controller/native_panel_service.d.ts +55 -0
  56. package/dist/controller/native_panel_service.js +214 -0
  57. package/dist/controller/native_panels.d.ts +61 -0
  58. package/dist/controller/native_panels.js +784 -0
  59. package/dist/controller/shared_helpers.d.ts +53 -0
  60. package/dist/controller/shared_helpers.js +399 -0
  61. package/dist/controller/state_types.d.ts +390 -0
  62. package/dist/controller/state_types.js +82 -0
  63. package/dist/controller/turn_rendering.d.ts +22 -0
  64. package/dist/controller/turn_rendering.js +288 -0
  65. package/dist/core/access.d.ts +11 -0
  66. package/dist/core/access.js +33 -0
  67. package/dist/core/attachment_files.d.ts +13 -0
  68. package/dist/core/attachment_files.js +180 -0
  69. package/dist/core/attachment_types.d.ts +22 -0
  70. package/dist/core/attachment_types.js +1 -0
  71. package/dist/core/attachments.d.ts +5 -3
  72. package/dist/core/attachments.js +2 -2
  73. package/dist/core/backend_registry.d.ts +22 -0
  74. package/dist/core/backend_registry.js +50 -0
  75. package/dist/core/backend_ui.d.ts +28 -0
  76. package/dist/core/backend_ui.js +1 -0
  77. package/dist/core/channel_events.d.ts +48 -0
  78. package/dist/core/channel_events.js +1 -0
  79. package/dist/core/channel_port.d.ts +52 -8
  80. package/dist/core/engine_spi.d.ts +20 -5
  81. package/dist/core/listener_scope.d.ts +11 -0
  82. package/dist/core/listener_scope.js +10 -0
  83. package/dist/core/message_text.d.ts +2 -0
  84. package/dist/core/message_text.js +28 -0
  85. package/dist/core/network_errors.d.ts +1 -0
  86. package/dist/core/network_errors.js +23 -0
  87. package/dist/core/orchestrator.d.ts +57 -22
  88. package/dist/core/orchestrator.js +877 -477
  89. package/dist/core/queued_engine_input.d.ts +12 -0
  90. package/dist/core/queued_engine_input.js +16 -0
  91. package/dist/core/runtime_supervisor.d.ts +19 -0
  92. package/dist/core/runtime_supervisor.js +45 -0
  93. package/dist/core/scope_operations.d.ts +7 -0
  94. package/dist/core/scope_operations.js +19 -0
  95. package/dist/core/stream_preview.js +21 -9
  96. package/dist/dsh/adapter.d.ts +48 -0
  97. package/dist/dsh/adapter.js +205 -0
  98. package/dist/dsh/backend.d.ts +7 -0
  99. package/dist/dsh/backend.js +21 -0
  100. package/dist/dsh/client.d.ts +46 -0
  101. package/dist/dsh/client.js +184 -0
  102. package/dist/dsh/permissions_plugin.d.ts +30 -0
  103. package/dist/dsh/permissions_plugin.js +21 -0
  104. package/dist/dsh/runtime.d.ts +20 -0
  105. package/dist/dsh/runtime.js +36 -0
  106. package/dist/dsh/ui.d.ts +38 -0
  107. package/dist/dsh/ui.js +342 -0
  108. package/dist/i18n.d.ts +4 -0
  109. package/dist/i18n.js +12 -0
  110. package/dist/main.js +144 -72
  111. package/dist/opencode/adapter.js +28 -9
  112. package/dist/opencode/controller.js +1 -1
  113. package/dist/store/channel_inbox.d.ts +14 -0
  114. package/dist/store/channel_inbox.js +21 -0
  115. package/dist/store/database.d.ts +10 -4
  116. package/dist/store/database.js +13 -3
  117. package/dist/store/task_journal.d.ts +32 -0
  118. package/dist/store/task_journal.js +70 -0
  119. package/dist/telegram/api.d.ts +2 -2
  120. package/dist/telegram/api.js +10 -28
  121. package/dist/telegram/gateway.d.ts +13 -27
  122. package/dist/telegram/gateway.js +86 -43
  123. package/dist/telegram/media.d.ts +2 -34
  124. package/dist/telegram/media.js +1 -180
  125. package/dist/telegram/task_preview.d.ts +13 -0
  126. package/dist/telegram/task_preview.js +92 -0
  127. package/dist/telegram/task_result.d.ts +2 -0
  128. package/dist/telegram/task_result.js +12 -0
  129. package/dist/telegram/text.d.ts +1 -1
  130. package/dist/telegram/text.js +2 -27
  131. package/dist/types.d.ts +2 -2
  132. package/docs/dsh.md +83 -0
  133. package/docs/release.md +13 -11
  134. package/docs/zh/architecture-review.md +106 -0
  135. package/docs/zh/dsh.md +83 -0
  136. package/docs/zh/release.md +13 -11
  137. package/docs/zh/telegram-experience.md +40 -0
  138. package/package.json +7 -5
@@ -1,18 +1,17 @@
1
+ import { ListenerScope } from './listener_scope.js';
2
+ import { randomUUID } from 'node:crypto';
1
3
  import fs from 'node:fs';
2
4
  import path from 'node:path';
3
5
  import os from 'node:os';
4
6
  import { parseCommand } from '../controller/commands.js';
5
- import { getTelegramCommands, getAntigravityTelegramCommands } from '../i18n.js';
6
- import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
7
- import { chunkTelegramMessage } from '../telegram/text.js';
7
+ import { chunkMessage, escapeHtml } from './message_text.js';
8
8
  import { stageInboundAttachments } from './attachments.js';
9
- import { buildAttachmentPrompt } from '../telegram/media.js';
10
- import { TurnQueueManager } from './turn_queue.js';
9
+ import { BackendRegistry } from './backend_registry.js';
10
+ import { decodeQueuedEngineInput } from './queued_engine_input.js';
11
+ import { ScopeOperations } from './scope_operations.js';
11
12
  import { renderStreamPreviewContent, buildFoldedToolsSummary, combineSummaryAndResponse } from './stream_preview.js';
12
- import { BRIDGE_SCOPE_TELEGRAM_PREFIX, BRIDGE_SCOPE_WEIXIN_PREFIX, parseTelegramTargetFromBridgeScope } from './bridge_scope.js';
13
- import { escapeTelegramHtml } from '../telegram/html.js';
14
13
  import { formatTokenUsageSummary, formatBackendTokenUsageBreakdown } from '../store/token_usage.js';
15
- import { isTransientNetworkError } from '../telegram/api.js';
14
+ import { isTransientNetworkError } from './network_errors.js';
16
15
  export const STREAM_THROTTLE_MS = 700;
17
16
  export const TYPING_INTERVAL_MS = 4000;
18
17
  export class UnifiedChannelOrchestrator {
@@ -21,12 +20,18 @@ export class UnifiedChannelOrchestrator {
21
20
  logger;
22
21
  bot;
23
22
  messaging;
24
- queueManager;
25
23
  customUi;
26
- backends = new Map();
24
+ backends;
25
+ scopeOperations = new ScopeOperations();
26
+ messageOperations = new ScopeOperations();
27
+ settlements = new Set();
28
+ stopped = false;
29
+ serviceUi;
27
30
  defaultBackendId;
28
31
  activeTurns = new Map();
29
32
  stalePanelDeleteTimers = new Map();
33
+ recoveryTimers = new Set();
34
+ ownsScope;
30
35
  backendProvider;
31
36
  get adapter() {
32
37
  return this.getAdapterForBackend(this.defaultBackendId);
@@ -37,9 +42,11 @@ export class UnifiedChannelOrchestrator {
37
42
  this.logger = options.logger;
38
43
  this.bot = options.bot;
39
44
  this.messaging = options.messaging;
40
- this.queueManager = new TurnQueueManager();
45
+ this.backends = new BackendRegistry(this);
46
+ this.serviceUi = options.serviceUi;
41
47
  this.customUi = options.customUi;
42
48
  this.backendProvider = options.backendProvider;
49
+ this.ownsScope = options.ownsScope ?? (() => true);
43
50
  if (options.backends) {
44
51
  for (const b of options.backends) {
45
52
  this.backends.set(b.id, b);
@@ -72,30 +79,25 @@ export class UnifiedChannelOrchestrator {
72
79
  }
73
80
  const firstId = Array.from(this.backends.keys())[0] ?? 'default';
74
81
  this.defaultBackendId = options.defaultBackendId ?? options.adapter?.id ?? firstId;
82
+ if (!this.backends.has(this.defaultBackendId))
83
+ throw new Error(`Default backend '${this.defaultBackendId}' is not registered`);
75
84
  }
76
85
  getAdapterForScope(scopeId) {
77
86
  const activeBackendId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
78
87
  return this.getAdapterForBackend(activeBackendId);
79
88
  }
80
89
  getBackendDescriptorForScope(scopeId) {
81
- const activeBackendId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
82
- return (this.backends.get(activeBackendId) ??
83
- this.backends.get(this.defaultBackendId) ?? {
84
- id: activeBackendId,
85
- name: activeBackendId,
86
- engineType: activeBackendId,
87
- adapter: this.adapter,
88
- });
90
+ const id = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
91
+ const backend = this.backends.get(id);
92
+ if (!backend)
93
+ throw new Error(`Backend '${id}' is unavailable; use /backend to select an available backend`);
94
+ return backend;
89
95
  }
90
96
  getAdapterForBackend(backendId) {
91
- const desc = this.backends.get(backendId);
92
- if (desc)
93
- return desc.adapter;
94
- const fallback = this.backends.get(this.defaultBackendId) ?? Array.from(this.backends.values())[0];
95
- if (!fallback) {
96
- throw new Error(`No engine adapter available in orchestrator for backend '${backendId}'`);
97
- }
98
- return fallback.adapter;
97
+ const backend = this.backends.get(backendId);
98
+ if (!backend)
99
+ throw new Error(`Backend '${backendId}' is unavailable`);
100
+ return backend.adapter;
99
101
  }
100
102
  registerBackend(desc) {
101
103
  this.backends.set(desc.id, desc);
@@ -109,9 +111,7 @@ export class UnifiedChannelOrchestrator {
109
111
  for (const b of dynamicList) {
110
112
  list.push(b);
111
113
  seen.add(b.id);
112
- if (!this.backends.has(b.id)) {
113
- this.backends.set(b.id, b);
114
- }
114
+ this.backends.set(b.id, b);
115
115
  }
116
116
  }
117
117
  catch (err) {
@@ -146,7 +146,7 @@ export class UnifiedChannelOrchestrator {
146
146
  });
147
147
  }
148
148
  scheduleStalePanelDeletion(scopeId, messageId) {
149
- if (this.config.telegramPanelTtlMs <= 0 || scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
149
+ if (this.config.telegramPanelTtlMs <= 0 || this.messaging.capabilities?.editableMessages === false) {
150
150
  return;
151
151
  }
152
152
  const key = `${scopeId}:${messageId}`;
@@ -166,6 +166,9 @@ export class UnifiedChannelOrchestrator {
166
166
  this.stalePanelDeleteTimers.set(key, timer);
167
167
  }
168
168
  async switchBackend(scopeId, targetBackendId, locale) {
169
+ return this.scopeOperations.run(scopeId, () => this.switchBackendNow(scopeId, targetBackendId, locale));
170
+ }
171
+ async switchBackendNow(scopeId, targetBackendId, locale) {
169
172
  const currentBackendId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
170
173
  const targetDesc = await this.resolveBackendDescriptor(targetBackendId);
171
174
  if (!targetDesc) {
@@ -173,6 +176,11 @@ export class UnifiedChannelOrchestrator {
173
176
  ? `未找到目标后端: ${targetBackendId}`
174
177
  : `Target backend not found: ${targetBackendId}`);
175
178
  }
179
+ if (this.stopped)
180
+ throw new Error('Orchestrator is stopped');
181
+ if (this.activeTurns.has(scopeId) || (currentBackendId !== targetBackendId && (this.store.countQueuedTurnInputs(scopeId) > 0 || this.store.taskJournal.listUnfinished(scopeId).length > 0))) {
182
+ throw new Error(locale === 'zh' ? '请先中断当前任务并清空队列,再切换后端。' : 'Interrupt the active turn and clear its queue before switching backends.');
183
+ }
176
184
  if (targetDesc.onSelect) {
177
185
  await targetDesc.onSelect(scopeId);
178
186
  }
@@ -206,7 +214,8 @@ export class UnifiedChannelOrchestrator {
206
214
  else {
207
215
  this.store.clearBinding(scopeId);
208
216
  }
209
- this.store.setChatSettings(scopeId, saved.model ?? null, saved.reasoningEffort ?? null);
217
+ this.store.setChatSettings(scopeId, saved.model ?? null, null);
218
+ this.store.setChatEngineEffort(scopeId, saved.reasoningEffort ?? null);
210
219
  if (saved.activeTurnMessageMode !== undefined && saved.activeTurnMessageMode !== null) {
211
220
  this.store.setChatActiveTurnMessageMode(scopeId, saved.activeTurnMessageMode ?? null);
212
221
  }
@@ -219,29 +228,29 @@ export class UnifiedChannelOrchestrator {
219
228
  }
220
229
  else {
221
230
  this.store.clearBinding(scopeId);
222
- const defaultEffort = targetDesc.engineType === 'antigravity' ? 'high' : 'medium';
223
- this.store.setChatSettings(scopeId, null, defaultEffort);
231
+ this.store.setChatModel(scopeId, null);
232
+ this.store.setChatEngineEffort(scopeId, targetDesc.defaults?.reasoningEffort ?? null);
233
+ this.store.setChatAccessPreset(scopeId, null);
234
+ this.store.setChatCollaborationMode(scopeId, null);
224
235
  this.store.setChatServiceTier(scopeId, null);
225
236
  }
226
- const isCodex = targetDesc.engineType === 'codex';
227
237
  const activeSettings = this.store.getChatSettings(scopeId);
228
238
  const modelText = activeSettings?.model ? `\`${activeSettings.model}\`` : (locale === 'zh' ? '引擎默认' : 'default');
229
- const effortText = activeSettings?.reasoningEffort ?? (isCodex ? 'medium' : 'high');
239
+ const effortText = activeSettings?.reasoningEffort ?? targetDesc.defaults?.reasoningEffort ?? 'default';
230
240
  const modeText = activeSettings?.activeTurnMessageMode ?? 'queue';
231
241
  const switchMsg = locale === 'zh'
232
242
  ? `🔄 **已切换至后端: ${targetDesc.name}** (\`${targetDesc.id}\`)\n\n` +
233
- `• **引擎类型**: ${isCodex ? 'OpenAI Codex (App Server)' : 'Google Antigravity (AGY)'}\n` +
234
- `• **绑定账号**: \`${targetDesc.account || '默认账号'}\`${targetDesc.details ? ` (${targetDesc.details})` : ''}\n` +
243
+ `• **引擎类型**: ${targetDesc.name}\n` +
244
+ (targetDesc.account ? `• **绑定账号**: \`${targetDesc.account}\`\n` : '') +
235
245
  `• **会话状态**: ${saved?.threadId ? `已恢复历史会话 (\`${saved.threadId.slice(0, 16)}…\`)\n• **工作目录**: \`${saved.cwd ?? this.config.defaultCwd}\`` : '新会话就绪 (发送消息将开启新会话)'}\n` +
236
246
  `• **记忆配置**: 模型 ${modelText} | 思考深度 \`${effortText}\` | 插话模式 \`${modeText}\`\n` +
237
- `• **专属指令**: \`/models\` (${isCodex ? 'Codex模型' : 'Gemini模型'}), \`/auth\` (${isCodex ? 'OpenAI账号池' : 'Google授权'}), \`/threads\` (${isCodex ? 'Codex历史' : 'AGY历史'})\n\n` +
238
- `⚡ **当前 Telegram Bot 已完全切换为 ${isCodex ? 'OpenAI Codex' : 'Google Antigravity'} 交互人格与执行引擎!**`
247
+ `• **操作**: \`/setup\` · \`/models\` · \`/threads\``
239
248
  : `🔄 **Switched to Backend: ${targetDesc.name}** (\`${targetDesc.id}\`)\n\n` +
240
- `• **Engine**: ${isCodex ? 'OpenAI Codex (App Server)' : 'Google Antigravity (AGY)'}\n` +
241
- `• **Account**: \`${targetDesc.account || 'default'}\`${targetDesc.details ? ` (${targetDesc.details})` : ''}\n` +
249
+ `• **Engine**: ${targetDesc.name}\n` +
250
+ (targetDesc.account ? `• **Account**: \`${targetDesc.account}\`\n` : '') +
242
251
  `• **Thread**: ${saved?.threadId ? `Restored (\`${saved.threadId.slice(0, 16)}…\`)\n• **Directory**: \`${saved.cwd ?? this.config.defaultCwd}\`` : 'Ready (next prompt starts new thread)'}\n` +
243
252
  `• **Restored Settings**: Model ${modelText} | Effort \`${effortText}\` | Message Mode \`${modeText}\`\n\n` +
244
- `⚡ **Telegram Bot is now fully operated by ${isCodex ? 'OpenAI Codex' : 'Google Antigravity'}!**`;
253
+ `• **Controls**: \`/setup\` · \`/models\` · \`/threads\``;
245
254
  try {
246
255
  await this.sendMessage(scopeId, switchMsg);
247
256
  }
@@ -249,11 +258,8 @@ export class UnifiedChannelOrchestrator {
249
258
  this.logger.warn('orchestrator.switch_announce_failed', { error: String(err) });
250
259
  }
251
260
  try {
252
- if (scopeId.startsWith(BRIDGE_SCOPE_TELEGRAM_PREFIX)) {
253
- const target = parseTelegramTargetFromBridgeScope(scopeId);
254
- const cmds = isCodex ? getTelegramCommands(locale) : getAntigravityTelegramCommands(locale);
255
- await this.bot.setChatCommands(target.chatId, cmds);
256
- }
261
+ if (targetDesc.commands)
262
+ await this.messaging.setScopeCommands?.(scopeId, targetDesc.commands(locale));
257
263
  }
258
264
  catch (err) {
259
265
  this.logger.warn('orchestrator.set_chat_commands_failed', { error: String(err) });
@@ -271,15 +277,44 @@ export class UnifiedChannelOrchestrator {
271
277
  cwd: saved?.cwd ?? null,
272
278
  };
273
279
  }
280
+ removeInboundConsumer = null;
281
+ intakeOperations = new ScopeOperations();
282
+ async receiveInbound(id, inbound) {
283
+ if (this.stopped || !this.ownsScope(inbound.event.scopeId))
284
+ return;
285
+ if (!this.store.channelInbox.accept(id, inbound))
286
+ return;
287
+ await this.intakeOperations.run(id, async () => {
288
+ if (this.stopped)
289
+ return;
290
+ if (!this.store.channelInbox.accept(id, inbound))
291
+ return;
292
+ if (inbound.kind === 'text')
293
+ await this.handleText(inbound.event);
294
+ else if (inbound.kind === 'callback')
295
+ await this.handleCallback(inbound.event);
296
+ else
297
+ await this.stopTask(inbound.event.scopeId, inbound.event.taskId);
298
+ if (!this.stopped)
299
+ this.store.channelInbox.complete(id);
300
+ });
301
+ }
302
+ inboundListeners = new ListenerScope();
274
303
  registerInboundHandlers() {
275
- this.bot.on('text', (event) => {
304
+ this.inboundListeners.clear();
305
+ this.removeInboundConsumer?.();
306
+ if (this.bot.setInboundConsumer) {
307
+ this.removeInboundConsumer = this.bot.setInboundConsumer((id, inbound) => this.receiveInbound(id, inbound));
308
+ return;
309
+ }
310
+ this.inboundListeners.listen(this.bot, 'text', (event) => {
276
311
  this.handleText(event).catch((err) => {
277
312
  this.logger.error('orchestrator.inbound_text_error', {
278
313
  error: err instanceof Error ? err.message : String(err),
279
314
  });
280
315
  });
281
316
  });
282
- this.bot.on('callback', (event) => {
317
+ this.inboundListeners.listen(this.bot, 'callback', (event) => {
283
318
  this.handleCallback(event).catch((err) => {
284
319
  this.logger.error('orchestrator.inbound_callback_error', {
285
320
  error: err instanceof Error ? err.message : String(err),
@@ -295,125 +330,266 @@ export class UnifiedChannelOrchestrator {
295
330
  });
296
331
  }
297
332
  async start() {
333
+ this.stopped = false;
334
+ await this.restoreDurableTasks();
335
+ for (const pending of this.store.channelInbox.pending())
336
+ if (this.ownsScope(pending.inbound.event.scopeId)) {
337
+ await this.receiveInbound(pending.id, pending.inbound).catch(error => this.logger.warn('orchestrator.intake_recovery_failed', { id: pending.id, error: String(error) }));
338
+ }
298
339
  await this.bot.start();
299
340
  this.logger.info('orchestrator.started', {
300
341
  defaultEngine: this.adapter.id,
301
342
  backends: Array.from(this.backends.keys()),
302
343
  });
303
- // Requeue any interrupted turns from previous crash/restart
304
- try {
305
- const requeued = this.store.requeueInterruptedQueuedTurnInputs();
306
- if (requeued > 0) {
307
- this.logger.info('orchestrator.requeued_interrupted_turns', { count: requeued });
344
+ }
345
+ recoveryEvent(scopeId, messageId = 0) {
346
+ return { scopeId, chatId: scopeId, topicId: null, chatType: 'private', userId: 'system', messageId,
347
+ text: '', attachments: [], entities: [], replyToBot: false };
348
+ }
349
+ async showRecovery(task) {
350
+ const zh = task.request.locale === 'zh';
351
+ const actions = [[
352
+ { text: zh ? '继续原任务' : 'Continue', callback_data: `engine:recover:continue:${task.id}` },
353
+ { text: zh ? '重新执行' : 'Retry', callback_data: `engine:recover:retry:${task.id}` },
354
+ { text: zh ? '放弃' : 'Cancel', callback_data: `engine:recover:cancel:${task.id}` },
355
+ ]];
356
+ await this.sendMessage(task.event.scopeId, zh
357
+ ? `⚠️ **任务执行结果待确认**\n任务: \`${task.id}\`\n${task.error ? `恢复失败: ${task.error}。` : '服务重启前可能已执行工具。'}原始输入与附件已保留,队列暂停。\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`
358
+ : `⚠️ **Task outcome requires confirmation**\nTask: \`${task.id}\`\n${task.error ? `Recovery failed: ${task.error}.` : 'Tools may already have run before the restart.'} Input and attachments are preserved; the queue is paused.\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`, actions);
359
+ }
360
+ async restoreDurableTasks() {
361
+ const tasks = this.store.taskJournal.listUnfinished().filter(task => this.ownsScope(task.event.scopeId));
362
+ // A queued journal entry proves the executor was never invoked, even if queue claiming was interrupted.
363
+ for (const task of tasks)
364
+ if (task.state === 'queued' && task.queueId && this.store.getQueuedTurnInput(task.queueId)?.status === 'processing') {
365
+ this.store.updateQueuedTurnInputStatus(task.queueId, 'queued');
308
366
  }
309
- }
310
- catch (err) {
311
- this.logger.warn('orchestrator.requeue_interrupted_error', { error: String(err) });
312
- }
313
- // Crash recovery: check interrupted preview messages from before restart
314
- try {
315
- const backendIds = new Set(this.backends.keys());
316
- for (const desc of this.backends.values()) {
317
- backendIds.add(desc.adapter.id);
367
+ // Claim uncertainty synchronously before recovering messages or starting any queued work.
368
+ for (const task of tasks)
369
+ if (task.state === 'running' && !task.result) {
370
+ this.store.taskJournal.update(task.id, 'awaiting_confirmation');
371
+ }
372
+ // Import pre-journal previews and processing queues conservatively; their outcome is unknown.
373
+ const journalScopes = new Set(tasks.filter(task => task.state !== 'queued').map(task => task.event.scopeId));
374
+ for (const preview of this.store.listActiveTurnPreviews()) {
375
+ if (!this.ownsScope(preview.scopeId) || journalScopes.has(preview.scopeId))
376
+ continue;
377
+ const backend = [...this.backends.values()].find(b => preview.turnId.startsWith(`${b.adapter.id}_`));
378
+ if (!backend)
379
+ continue;
380
+ const locale = this.store.getChatSettings(preview.scopeId)?.locale ?? 'zh';
381
+ const queued = this.store.listQueuedTurnInputs(preview.scopeId).find(q => q.status === 'processing');
382
+ const input = queued ? decodeQueuedEngineInput(queued.inputJson, queued.sourceSummary) : null;
383
+ const event = this.recoveryEvent(preview.scopeId, preview.messageId);
384
+ const task = this.captureTask(event, input?.prompt ?? (locale === 'zh' ? '继续重启前的任务' : 'Continue the task interrupted by restart'), locale, input?.stagedAttachments, queued?.queueId ?? null);
385
+ this.store.taskJournal.update(task.id, 'running', { previewMessageId: preview.messageId, request: { ...task.request, threadId: preview.threadId || null } });
386
+ this.store.taskJournal.update(task.id, 'awaiting_confirmation');
387
+ journalScopes.add(preview.scopeId);
388
+ }
389
+ for (const queued of this.store.listQueuedTurnInputs()) {
390
+ if (!this.ownsScope(queued.scopeId) || queued.status !== 'processing' || this.store.taskJournal.forQueue(queued.queueId))
391
+ continue;
392
+ if (!this.backends.has(this.store.getActiveBackend(queued.scopeId) || this.defaultBackendId))
393
+ continue;
394
+ const input = decodeQueuedEngineInput(queued.inputJson, queued.sourceSummary);
395
+ const event = { ...this.recoveryEvent(queued.scopeId, queued.messageId ?? 0), chatId: queued.chatId, topicId: queued.topicId };
396
+ const locale = this.store.getChatSettings(queued.scopeId)?.locale ?? 'zh';
397
+ const task = this.captureTask(event, input.prompt, locale, input.stagedAttachments, queued.queueId);
398
+ this.store.taskJournal.update(task.id, 'running');
399
+ this.store.taskJournal.update(task.id, 'awaiting_confirmation');
400
+ }
401
+ for (let task of this.store.taskJournal.listUnfinished()) {
402
+ if (!this.ownsScope(task.event.scopeId))
403
+ continue;
404
+ if (task.state === 'running' && task.result) {
405
+ const text = task.result.response || task.result.error || (task.request.locale === 'zh' ? '任务已结束' : 'Task ended');
406
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { delivery: chunkMessage(text, this.messaging.capabilities?.maxMessageLength) });
318
407
  }
319
- const activePreviews = this.store.listActiveTurnPreviews().filter((p) => {
320
- for (const bId of backendIds) {
321
- if (p.turnId.startsWith(`${bId}_`))
322
- return true;
408
+ if (task.state === 'awaiting_confirmation')
409
+ await this.showRecovery(task).catch(error => this.logger.warn('orchestrator.recovery_notice_failed', { taskId: task.id, error: String(error) }));
410
+ if (task.state === 'delivery_pending') {
411
+ try {
412
+ await this.deliverTaskChunks(task.id);
413
+ this.resolveDeliveredTask(task.id);
323
414
  }
324
- return false;
325
- });
326
- const recoveredScopes = new Set();
327
- for (const prev of activePreviews) {
328
- this.store.removeActiveTurnPreview(prev.turnId);
329
- recoveredScopes.add(prev.scopeId);
330
- // Bind existing threadId immediately so auto-resume continues in the exact same thread
331
- if (prev.threadId) {
332
- const currentBinding = this.store.getBinding(prev.scopeId);
333
- this.store.setBinding(prev.scopeId, prev.threadId, currentBinding?.cwd ?? this.config.defaultCwd);
415
+ catch (error) {
416
+ this.logger.warn('orchestrator.delivery_retry_failed', { taskId: task.id, error: String(error) });
334
417
  }
335
- const locale = this.store.getChatSettings(prev.scopeId)?.locale || 'zh';
336
- this.editMessage(prev.scopeId, prev.messageId, locale === 'zh'
337
- ? '🔄 **[Foxclaw 服务已重新加载/重启]**\n\n检测到上一轮任务执行被服务重启中断,正在自动继续执行…'
338
- : '🔄 **[FoxClaw Service Reloaded / Restarted]**\n\nInterrupted turn detected. Resuming automatically…').catch(() => { });
339
- const fakeEvent = {
340
- scopeId: prev.scopeId,
341
- chatId: prev.scopeId,
342
- topicId: null,
343
- chatType: 'private',
344
- userId: 'system',
345
- messageId: prev.messageId,
346
- text: '继续',
347
- attachments: [],
348
- entities: [],
349
- replyToBot: false,
350
- };
351
- const resumePrompt = prev.threadId
352
- ? (locale === 'zh' ? 'FoxClaw 服务重启中断,请在当前会话中从中断处继续完成刚才的工作。' : 'FoxClaw restarted while previous turn was running. Please resume and complete the work from where it left off.')
353
- : (locale === 'zh' ? '请继续完成上一步未完成的任务。' : 'Please continue and complete the unfinished task from previous step.');
354
- const resumeTimer = setTimeout(() => {
355
- this.startPromptTurn(fakeEvent, resumePrompt, locale, {
356
- reuseMessageId: prev.messageId,
357
- forcedThreadId: prev.threadId || undefined,
358
- }).catch((err) => {
359
- this.logger.warn('orchestrator.auto_resume_failed', {
360
- scopeId: prev.scopeId,
361
- error: String(err),
362
- });
418
+ }
419
+ if (task.state === 'accepted') {
420
+ const timer = setTimeout(() => {
421
+ this.recoveryTimers.delete(timer);
422
+ void this.scopeOperations.run(task.event.scopeId, async () => {
423
+ if (this.stopped)
424
+ return;
425
+ const attachments = task.request.stagedAttachments ?? await stageInboundAttachments(this.messaging, task.request.cwd, task.request.threadId || 'default', task.event.attachments, this.logger);
426
+ if (attachments.length !== task.event.attachments.length && !task.request.stagedAttachments)
427
+ throw new Error('Recovery attachment staging failed');
428
+ this.store.taskJournal.update(task.id, 'accepted', { request: { ...task.request, stagedAttachments: attachments } });
429
+ if (this.activeTurns.has(task.event.scopeId) || this.recoveryBlocksScope(task.event.scopeId)) {
430
+ await this.enqueuePromptTurn(task.event, task.sourcePrompt, task.request.locale, attachments, task.id);
431
+ }
432
+ else
433
+ await this.executeTurn(task.event, task.sourcePrompt, task.request.locale, 0, attachments, { taskId: task.id, forcedThreadId: task.request.threadId ?? undefined });
434
+ }).catch(async (error) => {
435
+ this.logger.warn('orchestrator.accepted_recovery_failed', { taskId: task.id, error: String(error) });
436
+ if (!this.stopped && this.store.taskJournal.get(task.id)?.state === 'accepted') {
437
+ const waiting = this.store.taskJournal.update(task.id, 'awaiting_confirmation', { error: String(error) });
438
+ await this.showRecovery(waiting).catch(() => { });
439
+ }
363
440
  });
364
441
  }, 1500);
365
- resumeTimer?.unref?.();
442
+ this.recoveryTimers.add(timer);
443
+ timer.unref();
366
444
  }
367
- // Check for queued turns on scopes without active previews
368
- const queuedInputs = this.store.listQueuedTurnInputs();
369
- for (const q of queuedInputs) {
370
- if (!recoveredScopes.has(q.scopeId) && !this.activeTurns.has(q.scopeId)) {
371
- recoveredScopes.add(q.scopeId);
372
- const locale = this.store.getChatSettings(q.scopeId)?.locale || 'zh';
373
- const fakeEvent = {
374
- scopeId: q.scopeId,
375
- chatId: q.chatId,
376
- topicId: q.topicId ?? null,
377
- chatType: q.chatType || 'private',
378
- userId: 'system',
379
- messageId: q.messageId ?? 0,
380
- text: q.sourceSummary,
381
- attachments: [],
382
- entities: [],
383
- replyToBot: false,
384
- };
385
- const drainTimer = setTimeout(() => {
386
- this.drainNextQueuedTurn(fakeEvent, locale).catch((err) => {
387
- this.logger.warn('orchestrator.auto_drain_failed', { scopeId: q.scopeId, error: String(err) });
388
- });
389
- }, 2000);
390
- drainTimer?.unref?.();
445
+ }
446
+ const scopes = new Set(this.store.listQueuedTurnInputs().filter(input => this.ownsScope(input.scopeId)).map(input => input.scopeId));
447
+ for (const scopeId of scopes) {
448
+ const timer = setTimeout(() => {
449
+ this.recoveryTimers.delete(timer);
450
+ void this.drainNextQueuedTurn(this.recoveryEvent(scopeId), this.store.getChatSettings(scopeId)?.locale ?? 'zh')
451
+ .catch(error => this.logger.warn('orchestrator.queue_recovery_failed', { scopeId, error: String(error) }));
452
+ }, 2000);
453
+ this.recoveryTimers.add(timer);
454
+ timer.unref();
455
+ }
456
+ }
457
+ resolveDeliveredTask(taskId) {
458
+ const task = this.store.taskJournal.get(taskId);
459
+ const status = task.result?.status ?? 'SUCCESS';
460
+ this.store.taskJournal.update(task.id, status === 'SUCCESS' ? 'completed' : status === 'INTERRUPTED' ? 'cancelled' : 'failed');
461
+ if (task.queueId)
462
+ this.store.updateQueuedTurnInputStatus(task.queueId, status === 'SUCCESS' ? 'completed' : status === 'INTERRUPTED' ? 'cancelled' : 'failed', task.result?.error ?? null);
463
+ this.store.removeActiveTurnPreviewByMessage(task.event.scopeId, task.previewMessageId);
464
+ }
465
+ async handleRecovery(scopeId, args, locale) {
466
+ await this.scopeOperations.run(scopeId, async () => {
467
+ const [action, id] = args.trim().split(/\s+/);
468
+ const tasks = this.store.taskJournal.listUnfinished(scopeId).filter(task => task.state === 'awaiting_confirmation' || task.state === 'delivery_pending');
469
+ const task = id ? tasks.find(task => task.id === id) : tasks.length === 1 ? tasks[0] : null;
470
+ if (!task || !['continue', 'retry', 'deliver', 'cancel'].includes(action ?? '')) {
471
+ await this.sendMessage(scopeId, tasks.length ? tasks.map(task => `\`${task.id}\` · ${task.state} · ${task.sourcePrompt}\n/recover continue|retry|deliver|cancel ${task.id}`).join('\n\n') : (locale === 'zh' ? '没有待恢复任务。' : 'No tasks to recover.'));
472
+ return;
473
+ }
474
+ if (this.activeTurns.has(scopeId)) {
475
+ await this.sendMessage(scopeId, locale === 'zh' ? '请先结束当前任务。' : 'Finish the active task first.');
476
+ return;
477
+ }
478
+ if (action === 'cancel') {
479
+ this.store.taskJournal.update(task.id, 'cancelled');
480
+ if (task.queueId)
481
+ this.store.updateQueuedTurnInputStatus(task.queueId, 'cancelled');
482
+ this.store.removeActiveTurnPreviewByMessage(scopeId, task.previewMessageId);
483
+ }
484
+ else if (task.state === 'delivery_pending') {
485
+ await this.deliverTaskChunks(task.id);
486
+ this.resolveDeliveredTask(task.id);
487
+ }
488
+ else if (action === 'continue' || action === 'retry') {
489
+ if (!this.backends.has(task.backendId) || this.getBackendDescriptorForScope(scopeId).id !== task.backendId || (this.store.getBinding(scopeId)?.cwd || this.config.defaultCwd) !== task.request.cwd) {
490
+ await this.sendMessage(scopeId, locale === 'zh' ? '原后端或目录不可用,请恢复原配置或放弃任务。' : 'Restore the original backend/directory, or cancel this task.');
491
+ return;
492
+ }
493
+ const prompt = action === 'continue'
494
+ ? `${locale === 'zh' ? '服务重启前任务可能已执行部分操作。请检查当前状态,继续完成原任务,避免重复已完成的操作。' : 'This task may have partially executed before restart. Inspect current state and finish it, avoiding duplicate actions.'}\n\n${task.sourcePrompt}`
495
+ : task.sourcePrompt;
496
+ const stagedAttachments = task.request.stagedAttachments ?? await stageInboundAttachments(this.messaging, task.request.cwd, task.request.threadId || 'default', task.event.attachments, this.logger);
497
+ if (stagedAttachments.length !== task.event.attachments.length && !task.request.stagedAttachments) {
498
+ await this.sendMessage(scopeId, locale === 'zh' ? '附件恢复失败,请稍后重试或放弃任务。' : 'Attachment recovery failed. Retry later or cancel this task.');
499
+ return;
391
500
  }
501
+ if (task.request.threadId)
502
+ this.store.setBinding(scopeId, task.request.threadId, task.request.cwd);
503
+ this.store.taskJournal.update(task.id, 'accepted', { request: { ...task.request, prompt, stagedAttachments }, result: null, delivery: [], deliveredChunks: 0, error: null });
504
+ await this.executeTurn(task.event, prompt, locale, 0, stagedAttachments, { taskId: task.id, queueId: task.queueId ?? undefined, reuseMessageId: task.previewMessageId || undefined, forcedThreadId: task.request.threadId ?? undefined });
505
+ }
506
+ });
507
+ if (!this.stopped)
508
+ await this.drainNextQueuedTurn(this.recoveryEvent(scopeId), locale);
509
+ }
510
+ async stopTask(scopeId, taskId) {
511
+ if (this.stopped || !this.ownsScope(scopeId))
512
+ return false;
513
+ return this.scopeOperations.run(scopeId, async () => {
514
+ const turn = this.activeTurns.get(scopeId);
515
+ if (!turn || turn.taskId !== taskId || turn.settling)
516
+ return false;
517
+ await this.cancelActiveTurn(scopeId);
518
+ return true;
519
+ });
520
+ }
521
+ async cancelActiveTurn(scopeId, preserveRecovery = false) {
522
+ const turn = this.activeTurns.get(scopeId);
523
+ if (!turn)
524
+ return;
525
+ if (turn.settling)
526
+ throw new Error('任务正在收尾,请等待结果交付完成。');
527
+ // Retain the slot until cancellation completes. A failed native cancellation must not permit overlap.
528
+ turn.suppressQueueDrain = true;
529
+ if (turn.flushTimer)
530
+ clearTimeout(turn.flushTimer);
531
+ if (turn.typingTimer)
532
+ clearInterval(turn.typingTimer);
533
+ await turn.execution.cancel();
534
+ await this.messageOperations.run(`${scopeId}:preview:${turn.taskId}`, () => this.messaging.endTaskPreview?.(scopeId, turn.taskId) ?? Promise.resolve());
535
+ if (!preserveRecovery && this.messaging.beginTaskPreview) {
536
+ const task = this.store.taskJournal.get(turn.taskId);
537
+ const folded = buildFoldedToolsSummary({ toolLines: turn.toolLines, toolCount: turn.toolCount, stepIndex: turn.stepIndex, startTime: turn.startTime, locale: task.request.locale });
538
+ const response = `${task.request.locale === 'zh' ? '🛑 已停止任务;排队任务保留。' : '🛑 Task stopped; queued tasks kept.'}${turn.accumulatedText.trim() ? `\n\n${turn.accumulatedText.trim()}` : ''}`;
539
+ this.store.taskJournal.update(turn.taskId, 'delivery_pending', { result: { kind: 'result', status: 'INTERRUPTED', response, conversationId: turn.threadId }, delivery: combineSummaryAndResponse(folded, response), deliveredChunks: 0, previewMessageId: turn.messageId });
540
+ try {
541
+ await this.deliverTaskChunks(turn.taskId);
542
+ this.resolveDeliveredTask(turn.taskId);
543
+ }
544
+ catch (error) {
545
+ this.logger.warn('orchestrator.stop_delivery_failed', { taskId: turn.taskId, error: String(error) });
392
546
  }
393
547
  }
394
- catch (err) {
395
- this.logger.warn('orchestrator.restore_active_turns_error', { error: String(err) });
548
+ if (this.activeTurns.get(scopeId) === turn)
549
+ this.activeTurns.delete(scopeId);
550
+ if (!preserveRecovery) {
551
+ this.store.removeActiveTurnPreview(turn.previewKey);
552
+ if (turn.queueId)
553
+ this.store.updateQueuedTurnInputStatus(turn.queueId, 'cancelled');
554
+ const task = this.store.taskJournal.get(turn.taskId);
555
+ if (!['cancelled', 'delivery_pending'].includes(task.state))
556
+ this.store.taskJournal.update(turn.taskId, 'cancelled');
396
557
  }
397
558
  }
398
559
  async stop() {
399
- this.bot.stop();
400
- for (const [scopeId, turn] of this.activeTurns.entries()) {
401
- if (turn.flushTimer)
402
- clearTimeout(turn.flushTimer);
403
- if (turn.typingTimer)
404
- clearInterval(turn.typingTimer);
405
- turn.execution.cancel();
406
- this.activeTurns.delete(scopeId);
407
- }
560
+ this.stopped = true;
561
+ this.inboundListeners.clear();
562
+ await this.bot.stop();
563
+ this.removeInboundConsumer?.();
564
+ this.removeInboundConsumer = null;
565
+ await this.intakeOperations.idle();
566
+ for (const timer of this.stalePanelDeleteTimers.values())
567
+ clearTimeout(timer);
568
+ this.stalePanelDeleteTimers.clear();
569
+ for (const timer of this.recoveryTimers)
570
+ clearTimeout(timer);
571
+ this.recoveryTimers.clear();
572
+ await Promise.allSettled([...this.activeTurns.keys()].map(scopeId => this.cancelActiveTurn(scopeId, true)));
573
+ await this.scopeOperations.idle();
574
+ await Promise.allSettled([...this.settlements]);
575
+ await this.messageOperations.idle();
576
+ await this.backends.stop();
408
577
  }
578
+ isIdleForServiceUpdate() {
579
+ return !this.store.taskJournal.listUnfinished().some(task => this.ownsScope(task.event.scopeId)) && this.activeTurns.size === 0 && this.settlements.size === 0 && this.scopeOperations.isIdle() && this.messageOperations.isIdle() && this.getPendingApprovals() === 0 &&
580
+ !this.store.listQueuedTurnInputs().some(input => this.ownsScope(input.scopeId));
581
+ }
582
+ getPendingApprovals() { return this.backends.getPendingApprovals(); }
409
583
  getActiveTurnsCount() {
410
584
  return this.activeTurns.size;
411
585
  }
586
+ ownsScopeId(scopeId) { return this.ownsScope(scopeId); }
587
+ hasExecutingTasks() { return this.activeTurns.size > 0 || !this.scopeOperations.isIdle() || this.settlements.size > 0; }
412
588
  hasActiveTurn(scopeId) {
413
589
  return this.activeTurns.has(scopeId);
414
590
  }
415
591
  async sendMessage(scopeId, text, keyboard) {
416
- const chunks = chunkTelegramMessage(text);
592
+ const chunks = chunkMessage(text, this.messaging.capabilities?.maxMessageLength);
417
593
  let lastMsgId = 0;
418
594
  for (let i = 0; i < chunks.length; i++) {
419
595
  const isLast = i === chunks.length - 1;
@@ -427,64 +603,97 @@ export class UnifiedChannelOrchestrator {
427
603
  await this.sendMessage(scopeId, text, keyboard);
428
604
  return;
429
605
  }
430
- await this.messaging.editRichMarkdown(scopeId, messageId, text, keyboard);
606
+ await this.messageOperations.run(`${scopeId}:${messageId}`, () => this.messaging.editRichMarkdown(scopeId, messageId, text, keyboard));
431
607
  }
432
- async safeDeliverChunks(scopeId, messageId, chunks) {
433
- if (!chunks || chunks.length === 0)
434
- return;
435
- try {
436
- if (messageId > 0) {
437
- await this.editMessage(scopeId, messageId, chunks[0]).catch(async () => {
438
- await this.sendMessage(scopeId, chunks[0]).catch(() => { });
439
- });
440
- }
441
- else {
442
- await this.sendMessage(scopeId, chunks[0]).catch(() => { });
443
- }
444
- for (let i = 1; i < chunks.length; i++) {
445
- await this.sendMessage(scopeId, chunks[i]).catch(() => { });
608
+ trackSettlement(work) {
609
+ const pending = work();
610
+ this.settlements.add(pending);
611
+ void pending.catch(error => this.logger.warn('orchestrator.settlement_failed', { error: String(error) }))
612
+ .finally(() => this.settlements.delete(pending));
613
+ }
614
+ async deliverTaskChunks(taskId) {
615
+ let task = this.store.taskJournal.get(taskId);
616
+ while (task.deliveredChunks < task.delivery.length) {
617
+ if (this.stopped)
618
+ throw new Error('Delivery interrupted by shutdown');
619
+ const index = task.deliveredChunks;
620
+ const chunk = task.delivery[index];
621
+ if (index === 0 && task.previewMessageId > 0 && this.messaging.capabilities?.editableMessages !== false) {
622
+ try {
623
+ await this.editMessage(task.event.scopeId, task.previewMessageId, chunk, []);
624
+ }
625
+ catch {
626
+ await this.sendMessage(task.event.scopeId, chunk);
627
+ }
446
628
  }
447
- }
448
- catch (err) {
449
- this.logger.warn('orchestrator.safe_deliver_chunks_failed', { error: String(err) });
629
+ else
630
+ await this.sendMessage(task.event.scopeId, chunk);
631
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { deliveredChunks: index + 1 });
450
632
  }
451
633
  }
634
+ async safeDeliverChunks(scopeId, messageId, chunks) {
635
+ const active = this.activeTurns.get(scopeId);
636
+ if (!active || this.stopped || active.suppressQueueDrain)
637
+ return;
638
+ await this.messageOperations.run(`${scopeId}:preview:${active.taskId}`, () => this.messaging.endTaskPreview?.(scopeId, active.taskId) ?? Promise.resolve());
639
+ const task = this.store.taskJournal.get(active.taskId);
640
+ messageId = active.messageId;
641
+ this.store.taskJournal.update(task.id, 'delivery_pending', { delivery: chunks, deliveredChunks: 0, previewMessageId: messageId });
642
+ await this.deliverTaskChunks(task.id);
643
+ }
452
644
  async safeDeliverMessage(scopeId, messageId, text) {
453
- const chunks = chunkTelegramMessage(text);
454
- await this.safeDeliverChunks(scopeId, messageId, chunks);
645
+ await this.safeDeliverChunks(scopeId, messageId, chunkMessage(text, this.messaging.capabilities?.maxMessageLength));
455
646
  }
456
647
  async handleText(event) {
648
+ if (this.stopped || !this.ownsScope(event.scopeId))
649
+ return;
650
+ const action = this.messaging.resolveAction?.(event);
651
+ if (action) {
652
+ await this.handleCallback(action);
653
+ return;
654
+ }
457
655
  const scopeId = event.scopeId;
458
656
  const locale = this.store.getChatSettings(scopeId)?.locale ?? 'zh';
459
- const isDefaultTopic = isDefaultTelegramScope({
460
- chatType: event.chatType,
461
- allowedChatId: this.config.tgAllowedChatId ?? null,
462
- allowedTopicId: this.config.tgAllowedTopicId ?? null,
463
- topicId: event.topicId ?? null,
464
- requireExplicitGroupAddressing: false,
465
- });
466
657
  const parsedCommand = parseCommand(event.text);
467
- const addressing = resolveTelegramAddressing({
468
- text: event.text,
469
- attachmentsCount: event.attachments?.length ?? 0,
470
- entities: event.entities,
471
- command: parsedCommand,
472
- botUsername: this.bot.username,
473
- isDefaultTopic,
474
- replyToBot: event.replyToBot,
475
- });
658
+ const addressing = this.messaging.resolveIncoming?.(event, this.config, this.bot.username) ??
659
+ (parsedCommand ? { kind: 'command', command: parsedCommand } :
660
+ event.text.trim() || event.attachments.length ? { kind: 'prompt', text: event.text } : { kind: 'ignore' });
476
661
  if (addressing.kind === 'ignore')
477
662
  return;
478
- if (this.customUi?.handleCustomInbound) {
479
- const handled = await this.customUi.handleCustomInbound(event, locale);
663
+ if (addressing.kind === 'command') {
664
+ const name = addressing.command.name.toLowerCase();
665
+ const args = addressing.command.args.join(' ').trim();
666
+ if (name === 'choose') {
667
+ await this.sendMessage(scopeId, locale === 'zh' ? '选择已过期或不属于此会话,请重新打开菜单。' : 'This choice expired or belongs to another scope. Open the menu again.');
668
+ return;
669
+ }
670
+ if (name === 'recover') {
671
+ await this.handleRecovery(scopeId, args, locale);
672
+ return;
673
+ }
674
+ if (await this.serviceUi?.handleCustomCommand?.(scopeId, name, args, locale, event))
675
+ return;
676
+ if (['backend', 'backends', 'engine', 'engines'].includes(name)) {
677
+ await this.handleBackendCommand(scopeId, args, locale);
678
+ return;
679
+ }
680
+ }
681
+ const backendId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
682
+ if (!this.backends.has(backendId)) {
683
+ await this.sendMessage(scopeId, locale === 'zh' ? `后端 \`${backendId}\` 当前不可用。发送 /backend 切换后端,原会话和设置已保留。` : `Backend \`${backendId}\` is unavailable. Use /backend to switch; its session and settings are preserved.`);
684
+ return;
685
+ }
686
+ const ui = this.backends.ui(this.getBackendDescriptorForScope(scopeId).id) ?? this.customUi;
687
+ if (ui?.handleCustomInbound) {
688
+ const handled = await ui.handleCustomInbound(event, locale);
480
689
  if (handled)
481
690
  return;
482
691
  }
483
692
  if (addressing.kind === 'command') {
484
693
  const commandName = addressing.command.name.toLowerCase();
485
694
  const argsString = addressing.command.args.join(' ').trim();
486
- if (this.customUi?.handleCustomCommand) {
487
- const handled = await this.customUi.handleCustomCommand(scopeId, commandName, argsString, locale, event);
695
+ if (ui?.handleCustomCommand) {
696
+ const handled = await ui.handleCustomCommand(scopeId, commandName, argsString, locale, event);
488
697
  if (handled)
489
698
  return;
490
699
  }
@@ -543,18 +752,35 @@ export class UnifiedChannelOrchestrator {
543
752
  await this.handleNewSession(scopeId, locale, argsString);
544
753
  return;
545
754
  }
755
+ await this.sendMessage(scopeId, locale === 'zh' ? `当前后端不支持 /${commandName}。发送 /help 查看可用操作。` : `This backend does not support /${commandName}. Send /help for available commands.`);
756
+ return;
546
757
  }
547
758
  if (addressing.kind === 'prompt') {
548
759
  await this.startPromptTurn(event, addressing.text, locale);
549
760
  }
550
761
  }
551
762
  async handleCallback(event) {
763
+ if (this.stopped || !this.ownsScope(event.scopeId))
764
+ return;
552
765
  const scopeId = event.scopeId;
553
- const locale = 'zh';
766
+ const locale = this.store.getChatSettings(scopeId)?.locale ?? 'zh';
554
767
  const data = event.data || '';
555
768
  const messageId = event.messageId;
556
- if (this.customUi?.handleCustomCallback) {
557
- const handled = await this.customUi.handleCustomCallback(scopeId, data, locale, messageId, event);
769
+ if (data.startsWith('engine:stop:')) {
770
+ const status = await this.stopTask(scopeId, data.slice('engine:stop:'.length));
771
+ await this.messaging.answerCallback(event.callbackQueryId, status ? (locale === 'zh' ? '已停止,排队任务保留' : 'Stopped; queued tasks kept') : (locale === 'zh' ? '此任务已结束' : 'This task has ended'));
772
+ return;
773
+ }
774
+ if (data.startsWith('engine:recover:')) {
775
+ const [, , action, id] = data.split(':');
776
+ await this.handleRecovery(scopeId, `${action} ${id}`, locale);
777
+ await this.messaging.answerCallback(event.callbackQueryId, '');
778
+ return;
779
+ }
780
+ const backendId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
781
+ const ui = this.backends.callbackOwner(data) ?? this.backends.ui(backendId) ?? (this.backends.has(backendId) ? this.customUi : undefined);
782
+ if (ui?.handleCustomCallback) {
783
+ const handled = await ui.handleCustomCallback(scopeId, data, locale, messageId, event);
558
784
  if (handled)
559
785
  return;
560
786
  }
@@ -576,6 +802,10 @@ export class UnifiedChannelOrchestrator {
576
802
  await this.sendSetupMenu(scopeId, locale, messageId);
577
803
  return;
578
804
  }
805
+ if (!this.backends.has(backendId)) {
806
+ await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh' ? '后端不可用,请发送 /backend 重新选择。' : 'Backend unavailable; select one with /backend.');
807
+ return;
808
+ }
579
809
  if (data.startsWith('engine:m:') || data.startsWith('setup:model:')) {
580
810
  const rawModel = data.startsWith('engine:m:')
581
811
  ? data.slice('engine:m:'.length)
@@ -592,6 +822,10 @@ export class UnifiedChannelOrchestrator {
592
822
  ? data.slice('engine:effort:'.length)
593
823
  : data.slice('setup:effort:'.length);
594
824
  const targetEffort = rawEffort === 'default' ? null : rawEffort;
825
+ if (targetEffort && !(await this.supportedEfforts(scopeId)).includes(targetEffort)) {
826
+ await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh' ? '当前模型不支持此推理档位。' : 'This model does not support that reasoning effort.');
827
+ return;
828
+ }
595
829
  const settings = this.store.getChatSettings(scopeId);
596
830
  if (targetEffort !== 'high' && settings?.serviceTier === 'boost') {
597
831
  this.store.setChatServiceTier(scopeId, null);
@@ -610,6 +844,10 @@ export class UnifiedChannelOrchestrator {
610
844
  return;
611
845
  }
612
846
  if (sub === 'boost') {
847
+ if (this.getBackendDescriptorForScope(scopeId).defaults?.boost === false) {
848
+ await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh' ? '当前后端不支持 Boost。' : 'Boost is not available on this backend.');
849
+ return;
850
+ }
613
851
  const isBoost = this.store.getChatSettings(scopeId)?.serviceTier === 'boost';
614
852
  const nextBoost = !isBoost;
615
853
  this.store.setChatServiceTier(scopeId, nextBoost ? 'boost' : null);
@@ -648,67 +886,112 @@ export class UnifiedChannelOrchestrator {
648
886
  }
649
887
  }
650
888
  }
889
+ captureTask(event, prompt, locale, stagedAttachments, queueId = null) {
890
+ const scopeId = event.scopeId;
891
+ const binding = this.store.getBinding(scopeId);
892
+ const settings = this.store.getChatSettings(scopeId);
893
+ const backend = this.getBackendDescriptorForScope(scopeId);
894
+ const boost = settings?.serviceTier === 'boost' && backend.defaults?.boost !== false;
895
+ const task = {
896
+ id: randomUUID(), backendId: backend.id, state: 'accepted', event, sourcePrompt: prompt,
897
+ request: { scopeId, prompt: boost && !prompt.startsWith('[Boost Mode:')
898
+ ? `[Boost Mode: Proceed with deep thinking, strategic planning, multiple perspectives, and rigorous verification.]\n\n${prompt}` : prompt,
899
+ stagedAttachments, threadId: binding?.threadId || null, cwd: binding?.cwd || this.config.defaultCwd,
900
+ model: settings?.model || 'default', effort: boost ? 'high' : settings?.reasoningEffort ?? backend.defaults?.reasoningEffort ?? null,
901
+ serviceTier: boost ? 'boost' : settings?.serviceTier ?? null, locale, accessPreset: settings?.accessPreset ?? 'default' },
902
+ queueId, previewMessageId: 0, result: null, delivery: [], deliveredChunks: 0, error: null,
903
+ createdAt: Date.now(), updatedAt: Date.now(),
904
+ };
905
+ this.store.taskJournal.insert(task);
906
+ return task;
907
+ }
908
+ recoveryBlocksScope(scopeId) {
909
+ return this.store.taskJournal.listUnfinished(scopeId).some(task => task.state === 'awaiting_confirmation' || task.state === 'delivery_pending');
910
+ }
651
911
  async startPromptTurn(event, prompt, locale, options) {
912
+ if (this.stopped)
913
+ return;
914
+ if (this.store.taskJournal.findReceipt(event, prompt))
915
+ return;
916
+ const task = this.captureTask(event, prompt, locale);
917
+ return this.scopeOperations.run(event.scopeId, () => this.startPromptTurnNow(event, prompt, locale, { ...options, taskId: task.id }));
918
+ }
919
+ async startPromptTurnNow(event, prompt, locale, options) {
920
+ if (this.stopped)
921
+ return;
652
922
  const scopeId = event.scopeId;
653
923
  const binding = this.store.getBinding(scopeId);
654
- const cwd = binding?.cwd || this.config.defaultCwd;
655
- const threadId = options?.forcedThreadId ?? binding?.threadId ?? 'default';
656
- let effectivePrompt = prompt;
924
+ const effectivePrompt = prompt;
925
+ const task = options?.taskId ? this.store.taskJournal.get(options.taskId) : this.captureTask(event, prompt, locale);
926
+ const cwd = task.request.cwd;
927
+ const threadId = options?.forcedThreadId ?? binding?.threadId ?? task.request.threadId ?? 'default';
928
+ if (options?.forcedThreadId)
929
+ task.request.threadId = options.forcedThreadId;
657
930
  let stagedAttachments;
658
- if (event.attachments && event.attachments.length > 0) {
659
- const nonDocJson = event.attachments.filter((a) => !(a.kind === 'document' && (a.fileName?.endsWith('.json') || a.mimeType?.includes('json'))));
660
- if (nonDocJson.length > 0) {
661
- const staged = await stageInboundAttachments(this.messaging, cwd, threadId, nonDocJson, this.logger);
662
- if (staged.length > 0) {
663
- stagedAttachments = staged;
664
- effectivePrompt = buildAttachmentPrompt(prompt, staged);
665
- }
931
+ try {
932
+ if (event.attachments.length) {
933
+ stagedAttachments = await stageInboundAttachments(this.messaging, cwd, threadId, event.attachments, this.logger);
934
+ if (stagedAttachments.length !== event.attachments.length)
935
+ throw new Error('Some attachments could not be saved; the task was not started');
666
936
  }
937
+ task.request.stagedAttachments = stagedAttachments;
938
+ this.store.taskJournal.update(task.id, 'accepted', { request: task.request });
939
+ }
940
+ catch (error) {
941
+ this.store.taskJournal.update(task.id, 'failed', { error: String(error) });
942
+ await this.sendMessage(scopeId, `⚠️ ${String(error)}`);
943
+ return;
667
944
  }
668
- if (this.activeTurns.has(scopeId)) {
945
+ const taskOptions = { ...options, taskId: task.id };
946
+ if (this.activeTurns.has(scopeId) || this.recoveryBlocksScope(scopeId)) {
669
947
  const settings = this.store.getChatSettings(scopeId);
670
948
  const mode = settings?.activeTurnMessageMode ?? 'queue';
671
- if (mode === 'steer') {
672
- const active = this.activeTurns.get(scopeId);
673
- if (active) {
674
- active.execution.cancel();
675
- if (active.typingTimer)
676
- clearInterval(active.typingTimer);
677
- if (active.flushTimer)
678
- clearTimeout(active.flushTimer);
679
- this.activeTurns.delete(scopeId);
680
- }
949
+ if (mode === 'steer' && !this.recoveryBlocksScope(scopeId)) {
950
+ await this.cancelActiveTurn(scopeId);
681
951
  await this.sendMessage(scopeId, locale === 'zh'
682
952
  ? `⚡ **已插话中断前置任务,立即开始新指令**:\n> ${effectivePrompt}`
683
953
  : `⚡ **Interrupted previous turn, executing new instruction**:\n> ${effectivePrompt}`);
684
- await this.executeTurn(event, effectivePrompt, locale, 0, stagedAttachments, options);
954
+ await this.executeTurn(event, effectivePrompt, locale, 0, stagedAttachments, taskOptions);
685
955
  return;
686
956
  }
687
- await this.enqueuePromptTurn(event, effectivePrompt, locale);
957
+ await this.enqueuePromptTurn(event, effectivePrompt, locale, stagedAttachments, task.id);
688
958
  return;
689
959
  }
690
- await this.executeTurn(event, effectivePrompt, locale, 0, stagedAttachments, options);
960
+ await this.executeTurn(event, effectivePrompt, locale, 0, stagedAttachments, taskOptions);
691
961
  }
692
- async enqueuePromptTurn(event, prompt, locale) {
962
+ cancelQueuedWork(scopeId) {
963
+ const count = this.store.cancelQueuedTurnInputs(scopeId);
964
+ for (const task of this.store.taskJournal.listUnfinished(scopeId)) {
965
+ if (task.state === 'queued' && task.queueId && this.store.getQueuedTurnInput(task.queueId)?.status === 'cancelled')
966
+ this.store.taskJournal.update(task.id, 'cancelled');
967
+ }
968
+ return count;
969
+ }
970
+ async enqueuePromptTurn(event, prompt, locale, stagedAttachments = [], taskId) {
693
971
  const scopeId = event.scopeId;
694
- const adapter = this.getAdapterForScope(scopeId);
972
+ const journalTask = taskId ? this.store.taskJournal.get(taskId) : null;
973
+ const adapter = journalTask ? this.getAdapterForBackend(journalTask.backendId) : this.getAdapterForScope(scopeId);
695
974
  const queueId = `${adapter.id}_q_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
696
975
  const binding = this.store.getBinding(scopeId);
697
- this.store.saveQueuedTurnInput({
698
- queueId,
699
- scopeId,
700
- chatId: String(event.chatId),
701
- chatType: event.chatType,
702
- topicId: event.topicId ?? null,
703
- threadId: binding?.threadId || '',
704
- inputJson: JSON.stringify([{ type: 'text', text: prompt }]),
705
- sourceSummary: prompt,
706
- messageId: event.messageId ?? null,
707
- status: 'queued',
708
- error: null,
709
- createdAt: Date.now(),
710
- updatedAt: Date.now(),
711
- resolvedAt: null,
976
+ this.store.taskJournal.atomic(() => {
977
+ this.store.saveQueuedTurnInput({
978
+ queueId,
979
+ scopeId,
980
+ chatId: String(event.chatId),
981
+ chatType: event.chatType,
982
+ topicId: event.topicId ?? null,
983
+ threadId: binding?.threadId || '',
984
+ inputJson: JSON.stringify({ version: 1, prompt, backendId: journalTask?.backendId ?? this.getBackendDescriptorForScope(scopeId).id, cwd: journalTask?.request.cwd ?? binding?.cwd ?? this.config.defaultCwd, stagedAttachments }),
985
+ sourceSummary: prompt,
986
+ messageId: event.messageId ?? null,
987
+ status: 'queued',
988
+ error: null,
989
+ createdAt: Date.now(),
990
+ updatedAt: Date.now(),
991
+ resolvedAt: null,
992
+ });
993
+ if (journalTask)
994
+ this.store.taskJournal.update(journalTask.id, 'queued', { queueId, request: { ...journalTask.request, stagedAttachments } });
712
995
  });
713
996
  const queueCount = this.store.countQueuedTurnInputs(scopeId);
714
997
  await this.sendMessage(scopeId, locale === 'zh'
@@ -717,53 +1000,56 @@ export class UnifiedChannelOrchestrator {
717
1000
  }
718
1001
  async drainNextQueuedTurn(event, locale) {
719
1002
  const scopeId = event.scopeId;
720
- try {
721
- const nextQueued = this.store.peekQueuedTurnInput(scopeId);
722
- if (!nextQueued)
723
- return;
724
- this.store.updateQueuedTurnInputStatus(nextQueued.queueId, 'completed');
725
- let parsedInput = nextQueued.sourceSummary;
726
- try {
727
- const arr = JSON.parse(nextQueued.inputJson);
728
- if (Array.isArray(arr) && arr[0]?.text) {
729
- parsedInput = arr[0].text;
1003
+ await this.scopeOperations.run(scopeId, async () => {
1004
+ while (!this.stopped && !this.activeTurns.has(scopeId) && !this.recoveryBlocksScope(scopeId)) {
1005
+ const queued = this.store.peekQueuedTurnInput(scopeId);
1006
+ if (!queued)
1007
+ return;
1008
+ this.store.updateQueuedTurnInputStatus(queued.queueId, 'processing');
1009
+ try {
1010
+ const input = decodeQueuedEngineInput(queued.inputJson, queued.sourceSummary);
1011
+ if ('backendId' in input && (input.backendId !== this.getBackendDescriptorForScope(scopeId).id || input.cwd !== (this.store.getBinding(scopeId)?.cwd || this.config.defaultCwd))) {
1012
+ throw new Error('Queued task context changed; start a new task in the selected backend/directory');
1013
+ }
1014
+ await this.sendMessage(scopeId, locale === 'zh' ? `⏭️ **开始执行排队任务**:\n> ${input.prompt}` : `⏭️ **Executing queued task**:\n> ${input.prompt}`);
1015
+ await this.executeTurn({ ...event, chatId: queued.chatId, topicId: queued.topicId, messageId: queued.messageId ?? 0, text: input.prompt, attachments: [] }, input.prompt, locale, 0, input.stagedAttachments, { queueId: queued.queueId, taskId: this.store.taskJournal.forQueue(queued.queueId)?.id });
1016
+ return;
1017
+ }
1018
+ catch (error) {
1019
+ if (this.stopped)
1020
+ return; // Preserve processing for restart recovery.
1021
+ this.store.updateQueuedTurnInputStatus(queued.queueId, 'failed', String(error));
1022
+ const task = this.store.taskJournal.forQueue(queued.queueId);
1023
+ if (task?.state === 'queued' || task?.state === 'accepted')
1024
+ this.store.taskJournal.update(task.id, 'failed', { error: String(error) });
1025
+ this.logger.warn('orchestrator.drain_queue_failed', { error: String(error) });
1026
+ await this.sendMessage(scopeId, `⚠️ ${String(error)}`).catch(() => { });
730
1027
  }
731
1028
  }
732
- catch {
733
- /* ignore */
734
- }
735
- await this.sendMessage(scopeId, locale === 'zh'
736
- ? `▶️ **开始执行排队任务**:\n> ${parsedInput}`
737
- : `▶️ **Executing queued task**:\n> ${parsedInput}`);
738
- void this.startPromptTurn({
739
- ...event,
740
- text: parsedInput,
741
- }, parsedInput, locale);
742
- }
743
- catch (err) {
744
- this.logger.warn('orchestrator.drain_queue_failed', { error: String(err) });
745
- }
1029
+ });
746
1030
  }
747
1031
  async executeTurn(event, prompt, locale, retryCount = 0, stagedAttachments, options) {
1032
+ if (this.stopped)
1033
+ throw new Error('Orchestrator is stopped');
748
1034
  const scopeId = event.scopeId;
749
1035
  const binding = this.store.getBinding(scopeId);
750
- const settings = this.store.getChatSettings(scopeId);
751
- const adapter = this.getAdapterForScope(scopeId);
752
- const cwd = binding?.cwd || this.config.defaultCwd;
753
- const threadId = options?.forcedThreadId ?? binding?.threadId ?? null;
754
- const model = settings?.model || 'default';
755
- const isBoost = settings?.serviceTier === 'boost';
756
- const effort = isBoost ? 'high' : (settings?.reasoningEffort ?? 'high');
757
- const effectivePrompt = isBoost && !prompt.startsWith('[Boost Mode:')
758
- ? `[Boost Mode: Proceed with deep thinking, strategic planning, multiple perspectives, and rigorous verification.]\n\n${prompt}`
759
- : prompt;
1036
+ let task = options?.taskId ? this.store.taskJournal.get(options.taskId) : null;
1037
+ if (!task)
1038
+ task = this.captureTask(event, prompt, locale, stagedAttachments, options?.queueId ?? null);
1039
+ const adapter = this.getAdapterForBackend(task.backendId);
1040
+ const req = { ...task.request, threadId: options?.forcedThreadId ?? binding?.threadId ?? task.request.threadId,
1041
+ stagedAttachments: stagedAttachments ?? task.request.stagedAttachments };
1042
+ const { cwd, threadId } = req;
1043
+ const isBoost = req.serviceTier === 'boost';
760
1044
  let initialMsgId = 0;
761
1045
  if (options?.reuseMessageId) {
762
1046
  initialMsgId = options.reuseMessageId;
763
1047
  try {
764
- await this.editMessage(scopeId, initialMsgId, locale === 'zh'
765
- ? `🔄 **[重启恢复] ${adapter.name} 正在继续执行…**`
766
- : `🔄 **[Resuming after restart] ${adapter.name} is continuing…**`);
1048
+ const text = locale === 'zh' ? `🔄 [重启恢复] ${adapter.name} 正在继续执行…` : `🔄 ${adapter.name} is continuing…`;
1049
+ if (this.messaging.beginTaskPreview)
1050
+ initialMsgId = await this.messaging.beginTaskPreview(scopeId, task.id, text, initialMsgId);
1051
+ else
1052
+ await this.editMessage(scopeId, initialMsgId, text, [[{ text: '🛑 停止 / Stop', callback_data: `engine:stop:${task.id}` }]]);
767
1053
  }
768
1054
  catch (editErr) {
769
1055
  this.logger.warn('orchestrator.reuse_message_failed', { error: String(editErr) });
@@ -771,15 +1057,18 @@ export class UnifiedChannelOrchestrator {
771
1057
  }
772
1058
  else {
773
1059
  try {
774
- initialMsgId = await this.sendMessage(scopeId, locale === 'zh'
1060
+ const initialText = locale === 'zh'
775
1061
  ? (isBoost ? `🚀 ${adapter.name} (Boost 模式) 正在深度思考中…` : `⏳ ${adapter.name} 正在思考中…`)
776
- : (isBoost ? `🚀 ${adapter.name} (Boost Mode) is thinking deeply…` : `⏳ ${adapter.name} is thinking…`));
1062
+ : (isBoost ? `🚀 ${adapter.name} (Boost Mode) is thinking deeply…` : `⏳ ${adapter.name} is thinking…`);
1063
+ initialMsgId = this.messaging.beginTaskPreview
1064
+ ? await this.messaging.beginTaskPreview(scopeId, task.id, initialText)
1065
+ : await this.sendMessage(scopeId, initialText, [[{ text: '🛑 停止 / Stop', callback_data: `engine:stop:${task.id}` }]]);
777
1066
  }
778
1067
  catch (sendErr) {
779
1068
  this.logger.warn('orchestrator.initial_message_failed', { error: String(sendErr) });
780
1069
  }
781
1070
  }
782
- const turnKey = `${adapter.id}_${scopeId}_${Date.now()}`;
1071
+ const turnKey = `${adapter.id}_${task.id}`;
783
1072
  try {
784
1073
  this.store.saveActiveTurnPreview({
785
1074
  turnId: turnKey,
@@ -791,17 +1080,7 @@ export class UnifiedChannelOrchestrator {
791
1080
  catch {
792
1081
  /* ignore */
793
1082
  }
794
- const req = {
795
- scopeId,
796
- prompt: effectivePrompt,
797
- stagedAttachments,
798
- threadId,
799
- cwd,
800
- model,
801
- effort,
802
- serviceTier: isBoost ? 'boost' : (settings?.serviceTier ?? null),
803
- locale,
804
- };
1083
+ task = this.store.taskJournal.update(task.id, task.state, { request: req, previewMessageId: initialMsgId });
805
1084
  if (adapter.preflightTurn) {
806
1085
  try {
807
1086
  await adapter.preflightTurn(req);
@@ -810,7 +1089,22 @@ export class UnifiedChannelOrchestrator {
810
1089
  this.logger.warn('orchestrator.preflight_failed', { error: String(err) });
811
1090
  }
812
1091
  }
813
- const execution = adapter.executeTurn(req);
1092
+ if (this.stopped) {
1093
+ await this.messaging.endTaskPreview?.(scopeId, task.id);
1094
+ this.store.removeActiveTurnPreview(turnKey);
1095
+ throw new Error('Orchestrator is stopped');
1096
+ }
1097
+ let execution;
1098
+ task = this.store.taskJournal.update(task.id, 'running', { result: null, delivery: [], deliveredChunks: 0 });
1099
+ try {
1100
+ execution = adapter.executeTurn(req);
1101
+ }
1102
+ catch (error) {
1103
+ await this.messaging.endTaskPreview?.(scopeId, task.id);
1104
+ this.store.removeActiveTurnPreview(turnKey);
1105
+ this.store.taskJournal.update(task.id, 'failed', { error: String(error) });
1106
+ throw error;
1107
+ }
814
1108
  const activeTurn = {
815
1109
  scopeId,
816
1110
  messageId: initialMsgId,
@@ -825,6 +1119,9 @@ export class UnifiedChannelOrchestrator {
825
1119
  toolCount: 0,
826
1120
  currentTool: null,
827
1121
  startTime: Date.now(),
1122
+ previewKey: turnKey,
1123
+ queueId: options?.queueId,
1124
+ taskId: task.id,
828
1125
  };
829
1126
  this.activeTurns.set(scopeId, activeTurn);
830
1127
  this.messaging.sendTypingInScope(scopeId).catch(() => { });
@@ -833,13 +1130,18 @@ export class UnifiedChannelOrchestrator {
833
1130
  scheduleFlush();
834
1131
  }, TYPING_INTERVAL_MS);
835
1132
  activeTurn.typingTimer?.unref?.();
1133
+ const ownsTurn = () => !this.stopped && !activeTurn.suppressQueueDrain && this.activeTurns.get(scopeId) === activeTurn;
836
1134
  const scheduleFlush = () => {
1135
+ if (!ownsTurn() || activeTurn.settling || this.messaging.capabilities?.editableMessages === false)
1136
+ return;
837
1137
  if (activeTurn.flushTimer)
838
1138
  return;
839
1139
  const elapsed = Date.now() - activeTurn.lastFlushTime;
840
1140
  const delay = Math.max(0, STREAM_THROTTLE_MS - elapsed);
841
1141
  activeTurn.flushTimer = setTimeout(() => {
842
1142
  activeTurn.flushTimer = null;
1143
+ if (!ownsTurn() || activeTurn.settling)
1144
+ return;
843
1145
  activeTurn.lastFlushTime = Date.now();
844
1146
  const elapsedSeconds = Math.max(1, Math.floor((Date.now() - activeTurn.startTime) / 1000));
845
1147
  const content = renderStreamPreviewContent({
@@ -852,31 +1154,49 @@ export class UnifiedChannelOrchestrator {
852
1154
  currentTool: activeTurn.currentTool,
853
1155
  elapsedSeconds,
854
1156
  });
855
- this.editMessage(activeTurn.scopeId, activeTurn.messageId, content).catch(() => { });
1157
+ void this.messageOperations.run(`${scopeId}:preview:${activeTurn.taskId}`, async () => {
1158
+ if (!ownsTurn() || activeTurn.settling)
1159
+ return;
1160
+ if (this.messaging.updateTaskPreview) {
1161
+ const messageId = await this.messaging.updateTaskPreview(scopeId, activeTurn.taskId, activeTurn.messageId, content);
1162
+ if (messageId !== activeTurn.messageId) {
1163
+ activeTurn.messageId = messageId;
1164
+ const journal = this.store.taskJournal.get(activeTurn.taskId);
1165
+ this.store.taskJournal.update(journal.id, journal.state, { previewMessageId: messageId });
1166
+ this.store.saveActiveTurnPreview({ turnId: activeTurn.previewKey, scopeId, threadId: activeTurn.threadId || '', messageId });
1167
+ }
1168
+ }
1169
+ else
1170
+ await this.messaging.editRichMarkdown(scopeId, activeTurn.messageId, content, [[{ text: '🛑 停止 / Stop', callback_data: `engine:stop:${activeTurn.taskId}` }]]);
1171
+ }).catch(() => { });
856
1172
  }, delay);
857
1173
  activeTurn.flushTimer?.unref?.();
858
1174
  };
859
1175
  execution.on('delta', (delta) => {
1176
+ if (!ownsTurn() || activeTurn.settling)
1177
+ return;
860
1178
  activeTurn.accumulatedText += delta;
861
1179
  scheduleFlush();
862
1180
  });
863
1181
  execution.on('tool', (tool) => {
1182
+ if (!ownsTurn() || activeTurn.settling)
1183
+ return;
864
1184
  if (tool.stepIndex && tool.stepIndex > 0) {
865
1185
  activeTurn.stepIndex = tool.stepIndex;
866
1186
  }
867
1187
  const icon = tool.status === 'running' ? '⚙️' : tool.status === 'failed' ? '❌' : '✅';
868
- const summaryText = tool.summary ? ` · ${escapeTelegramHtml(tool.summary)}` : '';
869
- const line = `${icon} <code>${escapeTelegramHtml(tool.name)}</code>${summaryText}`;
1188
+ const summaryText = tool.summary ? ` · ${escapeHtml(tool.summary.slice(0, 180))}` : '';
1189
+ const line = `${icon} <code>${escapeHtml(tool.name.slice(0, 100))}</code>${summaryText}`;
870
1190
  if (tool.status === 'running') {
871
1191
  activeTurn.toolCount += 1;
872
- activeTurn.currentTool = tool.summary ? `${tool.name} (${tool.summary})` : tool.name;
1192
+ activeTurn.currentTool = tool.summary ? `${tool.name.slice(0, 100)} (${tool.summary.slice(0, 180)})` : tool.name;
873
1193
  activeTurn.toolLines.push(line);
874
1194
  }
875
1195
  else {
876
1196
  if (activeTurn.currentTool && activeTurn.currentTool.startsWith(tool.name)) {
877
1197
  activeTurn.currentTool = null;
878
1198
  }
879
- const lastIdx = activeTurn.toolLines.findLastIndex((l) => l.includes(`<code>${escapeTelegramHtml(tool.name)}</code>`));
1199
+ const lastIdx = activeTurn.toolLines.findLastIndex((l) => l.includes(`<code>${escapeHtml(tool.name.slice(0, 100))}</code>`));
880
1200
  if (lastIdx !== -1) {
881
1201
  activeTurn.toolLines[lastIdx] = line;
882
1202
  }
@@ -887,7 +1207,7 @@ export class UnifiedChannelOrchestrator {
887
1207
  scheduleFlush();
888
1208
  });
889
1209
  execution.on('conversation', (convId) => {
890
- if (!convId)
1210
+ if (!convId || !ownsTurn() || activeTurn.settling)
891
1211
  return;
892
1212
  activeTurn.threadId = convId;
893
1213
  this.store.setBinding(scopeId, convId, cwd);
@@ -897,55 +1217,176 @@ export class UnifiedChannelOrchestrator {
897
1217
  threadId: convId,
898
1218
  messageId: activeTurn.messageId,
899
1219
  });
1220
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { request: { ...req, threadId: convId } });
900
1221
  this.syncCurrentBackendSettings(scopeId);
901
1222
  });
902
1223
  const retryTurn = async (nextRetryCount) => {
903
- await this.executeTurn(event, prompt, locale, nextRetryCount, stagedAttachments, options);
904
- };
905
- execution.on('result', async (res) => {
906
- try {
907
- if (activeTurn.flushTimer)
908
- clearTimeout(activeTurn.flushTimer);
909
- if (activeTurn.typingTimer)
910
- clearInterval(activeTurn.typingTimer);
1224
+ await this.scopeOperations.run(scopeId, async () => {
1225
+ if (!ownsTurn() || activeTurn.suppressQueueDrain)
1226
+ return;
1227
+ await this.messageOperations.run(`${scopeId}:preview:${activeTurn.taskId}`, () => this.messaging.endTaskPreview?.(scopeId, activeTurn.taskId) ?? Promise.resolve());
911
1228
  this.activeTurns.delete(scopeId);
912
1229
  this.store.removeActiveTurnPreview(turnKey);
913
- if (res.conversationId && (!binding?.threadId || binding.threadId !== res.conversationId)) {
914
- this.store.setBinding(scopeId, res.conversationId, cwd);
915
- this.syncCurrentBackendSettings(scopeId);
1230
+ try {
1231
+ await this.executeTurn(event, prompt, locale, nextRetryCount, stagedAttachments, { ...options, reuseMessageId: activeTurn.messageId || undefined, taskId: activeTurn.taskId });
916
1232
  }
917
- if (res.usage) {
918
- this.store.recordTokenUsage({
919
- inputTokens: res.usage.inputTokens,
920
- outputTokens: res.usage.outputTokens,
921
- cachedTokens: res.usage.cachedTokens,
922
- totalTokens: res.usage.totalTokens,
923
- }, adapter.id);
1233
+ catch (error) {
1234
+ if (!this.stopped && !this.activeTurns.has(scopeId))
1235
+ this.activeTurns.set(scopeId, activeTurn);
1236
+ throw error;
924
1237
  }
925
- if (res.status === 'SUCCESS') {
926
- let finalText = (res.response || '').trim();
927
- if (!finalText && activeTurn.accumulatedText) {
928
- finalText = activeTurn.accumulatedText.trim();
929
- }
930
- const foldedTools = buildFoldedToolsSummary({
931
- stepIndex: activeTurn.stepIndex,
932
- toolLines: activeTurn.toolLines,
933
- toolCount: activeTurn.toolCount || activeTurn.toolLines.length,
934
- startTime: activeTurn.startTime,
935
- usage: res.usage,
936
- locale,
937
- });
938
- const chunks = combineSummaryAndResponse(foldedTools, finalText || '(无输出 / No output)');
939
- await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
940
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1238
+ });
1239
+ };
1240
+ const finishTurn = async (status, error) => {
1241
+ if (!ownsTurn())
1242
+ return;
1243
+ this.activeTurns.delete(scopeId);
1244
+ this.store.removeActiveTurnPreview(turnKey);
1245
+ const task = this.store.taskJournal.get(activeTurn.taskId);
1246
+ if (task.state === 'delivery_pending' && task.deliveredChunks < task.delivery.length) {
1247
+ this.logger.warn('orchestrator.delivery_pending', { taskId: task.id, scopeId });
1248
+ return;
1249
+ }
1250
+ this.store.taskJournal.update(task.id, status === 'SUCCESS' ? 'completed' : status === 'INTERRUPTED' ? 'cancelled' : 'failed', { error: error ?? null });
1251
+ if (activeTurn.queueId)
1252
+ this.store.updateQueuedTurnInputStatus(activeTurn.queueId, status === 'SUCCESS' ? 'completed' : status === 'INTERRUPTED' ? 'cancelled' : 'failed', error ?? null);
1253
+ if (!activeTurn.suppressQueueDrain)
1254
+ await this.drainNextQueuedTurn(event, locale);
1255
+ };
1256
+ execution.on('result', (res) => {
1257
+ this.trackSettlement(async () => {
1258
+ if (!ownsTurn() || activeTurn.settling)
941
1259
  return;
1260
+ activeTurn.settling = true;
1261
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { result: res, request: { ...req, threadId: res.conversationId || activeTurn.threadId } });
1262
+ try {
1263
+ if (activeTurn.flushTimer)
1264
+ clearTimeout(activeTurn.flushTimer);
1265
+ if (activeTurn.typingTimer)
1266
+ clearInterval(activeTurn.typingTimer);
1267
+ if (res.conversationId && (!binding?.threadId || binding.threadId !== res.conversationId)) {
1268
+ this.store.setBinding(scopeId, res.conversationId, cwd);
1269
+ this.syncCurrentBackendSettings(scopeId);
1270
+ }
1271
+ if (res.usage) {
1272
+ this.store.recordTokenUsage({
1273
+ inputTokens: res.usage.inputTokens,
1274
+ outputTokens: res.usage.outputTokens,
1275
+ cachedTokens: res.usage.cachedTokens,
1276
+ totalTokens: res.usage.totalTokens,
1277
+ }, adapter.id);
1278
+ }
1279
+ if (res.status === 'SUCCESS') {
1280
+ let finalText = (res.response || '').trim();
1281
+ if (!finalText && activeTurn.accumulatedText) {
1282
+ finalText = activeTurn.accumulatedText.trim();
1283
+ }
1284
+ const foldedTools = buildFoldedToolsSummary({
1285
+ stepIndex: activeTurn.stepIndex,
1286
+ toolLines: activeTurn.toolLines,
1287
+ toolCount: activeTurn.toolCount || activeTurn.toolLines.length,
1288
+ startTime: activeTurn.startTime,
1289
+ usage: res.usage,
1290
+ locale,
1291
+ });
1292
+ const chunks = combineSummaryAndResponse(foldedTools, finalText || '(无输出 / No output)');
1293
+ await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
1294
+ return;
1295
+ }
1296
+ if (res.status === 'ERROR') {
1297
+ const errorText = (res.error || res.response || res.error || 'Unknown error').trim();
1298
+ if (adapter.handleTurnError) {
1299
+ try {
1300
+ const handled = await adapter.handleTurnError({
1301
+ error: errorText,
1302
+ request: req,
1303
+ retryCount,
1304
+ retryTurn,
1305
+ sendMessage: (text) => this.sendMessage(scopeId, text),
1306
+ editMessage: (messageId, text) => this.editMessage(scopeId, messageId, text),
1307
+ });
1308
+ if (handled)
1309
+ return;
1310
+ }
1311
+ catch (adapterErr) {
1312
+ this.logger.warn('orchestrator.adapter_handle_turn_error_failed', { error: String(adapterErr) });
1313
+ }
1314
+ }
1315
+ if (isTransientNetworkError(errorText) && retryCount < 3) {
1316
+ const backoffMs = Math.min(15000, 3000 * Math.pow(2, retryCount));
1317
+ const delaySec = Math.round(backoffMs / 1000);
1318
+ try {
1319
+ await this.sendMessage(scopeId, locale === 'zh'
1320
+ ? `🌐 **网络波动重试 (第 ${retryCount + 1}/3 次)**: 正在等待网络稳定 (${delaySec}s) 后自动恢复任务…`
1321
+ : `🌐 **Network Retry (${retryCount + 1}/3)**: Waiting for connection (${delaySec}s) to resume task…`);
1322
+ }
1323
+ catch {
1324
+ /* ignore messaging error during network glitch */
1325
+ }
1326
+ await new Promise((r) => setTimeout(r, backoffMs));
1327
+ try {
1328
+ await retryTurn(retryCount + 1);
1329
+ }
1330
+ catch (retryErr) {
1331
+ this.logger.error('orchestrator.retry_turn_failed', { error: String(retryErr) });
1332
+ }
1333
+ return;
1334
+ }
1335
+ // If the agent actually produced substantive response text, do NOT mask it with an error box!
1336
+ let finalText = (res.response || '').trim();
1337
+ if (!finalText && activeTurn.accumulatedText) {
1338
+ finalText = activeTurn.accumulatedText.trim();
1339
+ }
1340
+ const isPureError = !finalText ||
1341
+ finalText === errorText ||
1342
+ finalText.startsWith('Process exited with code') ||
1343
+ finalText.startsWith('Verification Required') ||
1344
+ finalText.startsWith('Error:') ||
1345
+ finalText.length <= 20;
1346
+ if (!isPureError) {
1347
+ const foldedTools = buildFoldedToolsSummary({
1348
+ stepIndex: activeTurn.stepIndex,
1349
+ toolLines: activeTurn.toolLines,
1350
+ toolCount: activeTurn.toolCount || activeTurn.toolLines.length,
1351
+ startTime: activeTurn.startTime,
1352
+ usage: res.usage,
1353
+ locale,
1354
+ });
1355
+ const warningNote = errorText && errorText !== 'Unknown error' && errorText !== 'Antigravity execution failed'
1356
+ ? `\n\n⚠️ <i>(注意:任务结束时伴随提示: ${escapeHtml(errorText.slice(0, 120))})</i>`
1357
+ : '';
1358
+ const chunks = combineSummaryAndResponse(foldedTools, finalText + warningNote);
1359
+ await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
1360
+ return;
1361
+ }
1362
+ await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **${adapter.name} 错误**:\n\`\`\`\n${errorText}\n\`\`\``);
1363
+ return;
1364
+ }
1365
+ await this.safeDeliverMessage(scopeId, activeTurn.messageId, activeTurn.accumulatedText || '⚠️ 执行结束');
1366
+ }
1367
+ catch (fatalErr) {
1368
+ this.logger.error('orchestrator.turn_result_unhandled', { error: String(fatalErr) });
942
1369
  }
943
- if (res.status === 'ERROR') {
944
- const errorText = (res.error || res.response || res.error || 'Unknown error').trim();
1370
+ finally {
1371
+ await finishTurn(res.status, res.error).catch(error => this.logger.warn('orchestrator.finish_failed', { error: String(error) }));
1372
+ }
1373
+ });
1374
+ });
1375
+ execution.on('error', (err) => {
1376
+ this.trackSettlement(async () => {
1377
+ if (!ownsTurn() || activeTurn.settling)
1378
+ return;
1379
+ activeTurn.settling = true;
1380
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { result: { kind: 'result', status: 'ERROR', response: activeTurn.accumulatedText, error: err.message, conversationId: activeTurn.threadId } });
1381
+ try {
1382
+ if (activeTurn.flushTimer)
1383
+ clearTimeout(activeTurn.flushTimer);
1384
+ if (activeTurn.typingTimer)
1385
+ clearInterval(activeTurn.typingTimer);
945
1386
  if (adapter.handleTurnError) {
946
1387
  try {
947
1388
  const handled = await adapter.handleTurnError({
948
- error: errorText,
1389
+ error: err.message,
949
1390
  request: req,
950
1391
  retryCount,
951
1392
  retryTurn,
@@ -956,10 +1397,10 @@ export class UnifiedChannelOrchestrator {
956
1397
  return;
957
1398
  }
958
1399
  catch (adapterErr) {
959
- this.logger.warn('orchestrator.adapter_handle_turn_error_failed', { error: String(adapterErr) });
1400
+ this.logger.warn('orchestrator.adapter_handle_error_failed', { error: String(adapterErr) });
960
1401
  }
961
1402
  }
962
- if (isTransientNetworkError(errorText) && retryCount < 3) {
1403
+ if (isTransientNetworkError(err.message) && retryCount < 3) {
963
1404
  const backoffMs = Math.min(15000, 3000 * Math.pow(2, retryCount));
964
1405
  const delaySec = Math.round(backoffMs / 1000);
965
1406
  try {
@@ -968,7 +1409,7 @@ export class UnifiedChannelOrchestrator {
968
1409
  : `🌐 **Network Retry (${retryCount + 1}/3)**: Waiting for connection (${delaySec}s) to resume task…`);
969
1410
  }
970
1411
  catch {
971
- /* ignore messaging error during network glitch */
1412
+ /* ignore */
972
1413
  }
973
1414
  await new Promise((r) => setTimeout(r, backoffMs));
974
1415
  try {
@@ -979,132 +1420,64 @@ export class UnifiedChannelOrchestrator {
979
1420
  }
980
1421
  return;
981
1422
  }
982
- // If the agent actually produced substantive response text, do NOT mask it with an error box!
983
- let finalText = (res.response || '').trim();
984
- if (!finalText && activeTurn.accumulatedText) {
985
- finalText = activeTurn.accumulatedText.trim();
986
- }
987
- const isPureError = !finalText ||
988
- finalText === errorText ||
989
- finalText.startsWith('Process exited with code') ||
990
- finalText.startsWith('Verification Required') ||
991
- finalText.startsWith('Error:') ||
992
- finalText.length <= 20;
993
- if (!isPureError) {
1423
+ if (activeTurn.accumulatedText && activeTurn.accumulatedText.trim().length > 20) {
994
1424
  const foldedTools = buildFoldedToolsSummary({
995
1425
  stepIndex: activeTurn.stepIndex,
996
1426
  toolLines: activeTurn.toolLines,
997
1427
  toolCount: activeTurn.toolCount || activeTurn.toolLines.length,
998
1428
  startTime: activeTurn.startTime,
999
- usage: res.usage,
1000
1429
  locale,
1001
1430
  });
1002
- const warningNote = errorText && errorText !== 'Unknown error' && errorText !== 'Antigravity execution failed'
1003
- ? `\n\n⚠️ <i>(注意:任务结束时伴随提示: ${escapeTelegramHtml(errorText.slice(0, 120))})</i>`
1004
- : '';
1005
- const chunks = combineSummaryAndResponse(foldedTools, finalText + warningNote);
1431
+ const fullAnswer = activeTurn.accumulatedText.trim() +
1432
+ `\n\n⚠️ <i>(注意:任务执行中途异常中断: ${escapeHtml(err.message.slice(0, 120))})</i>`;
1433
+ const chunks = combineSummaryAndResponse(foldedTools, fullAnswer);
1006
1434
  await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
1007
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1008
1435
  return;
1009
1436
  }
1010
- await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **${adapter.name} 错误**:\n\`\`\`\n${errorText}\n\`\`\``);
1011
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1012
- return;
1437
+ await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **执行异常**:\n\`\`\`\n${err.message}\n\`\`\``);
1013
1438
  }
1014
- await this.safeDeliverMessage(scopeId, activeTurn.messageId, activeTurn.accumulatedText || '⚠️ 执行结束');
1015
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1016
- }
1017
- catch (fatalErr) {
1018
- this.logger.error('orchestrator.turn_result_unhandled', { error: String(fatalErr) });
1019
- }
1020
- });
1021
- execution.on('error', async (err) => {
1022
- try {
1023
- if (activeTurn.flushTimer)
1024
- clearTimeout(activeTurn.flushTimer);
1025
- if (activeTurn.typingTimer)
1026
- clearInterval(activeTurn.typingTimer);
1027
- this.activeTurns.delete(scopeId);
1028
- this.store.removeActiveTurnPreview(turnKey);
1029
- if (adapter.handleTurnError) {
1030
- try {
1031
- const handled = await adapter.handleTurnError({
1032
- error: err.message,
1033
- request: req,
1034
- retryCount,
1035
- retryTurn,
1036
- sendMessage: (text) => this.sendMessage(scopeId, text),
1037
- editMessage: (messageId, text) => this.editMessage(scopeId, messageId, text),
1038
- });
1039
- if (handled)
1040
- return;
1041
- }
1042
- catch (adapterErr) {
1043
- this.logger.warn('orchestrator.adapter_handle_error_failed', { error: String(adapterErr) });
1044
- }
1439
+ catch (fatalErr) {
1440
+ this.logger.error('orchestrator.turn_error_unhandled', { error: String(fatalErr) });
1045
1441
  }
1046
- if (isTransientNetworkError(err.message) && retryCount < 3) {
1047
- const backoffMs = Math.min(15000, 3000 * Math.pow(2, retryCount));
1048
- const delaySec = Math.round(backoffMs / 1000);
1049
- try {
1050
- await this.sendMessage(scopeId, locale === 'zh'
1051
- ? `🌐 **网络波动重试 (第 ${retryCount + 1}/3 次)**: 正在等待网络稳定 (${delaySec}s) 后自动恢复任务…`
1052
- : `🌐 **Network Retry (${retryCount + 1}/3)**: Waiting for connection (${delaySec}s) to resume task…`);
1053
- }
1054
- catch {
1055
- /* ignore */
1056
- }
1057
- await new Promise((r) => setTimeout(r, backoffMs));
1058
- try {
1059
- await retryTurn(retryCount + 1);
1060
- }
1061
- catch (retryErr) {
1062
- this.logger.error('orchestrator.retry_turn_failed', { error: String(retryErr) });
1063
- }
1064
- return;
1442
+ finally {
1443
+ await finishTurn('ERROR', err.message).catch(error => this.logger.warn('orchestrator.finish_failed', { error: String(error) }));
1065
1444
  }
1066
- if (activeTurn.accumulatedText && activeTurn.accumulatedText.trim().length > 20) {
1067
- const foldedTools = buildFoldedToolsSummary({
1068
- stepIndex: activeTurn.stepIndex,
1069
- toolLines: activeTurn.toolLines,
1070
- toolCount: activeTurn.toolCount || activeTurn.toolLines.length,
1071
- startTime: activeTurn.startTime,
1072
- locale,
1073
- });
1074
- const fullAnswer = activeTurn.accumulatedText.trim() +
1075
- `\n\n⚠️ <i>(注意:任务执行中途异常中断: ${escapeTelegramHtml(err.message.slice(0, 120))})</i>`;
1076
- const chunks = combineSummaryAndResponse(foldedTools, fullAnswer);
1077
- await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
1078
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1079
- return;
1080
- }
1081
- await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **执行异常**:\n\`\`\`\n${err.message}\n\`\`\``);
1082
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
1083
- }
1084
- catch (fatalErr) {
1085
- this.logger.error('orchestrator.turn_error_unhandled', { error: String(fatalErr) });
1086
- }
1445
+ });
1087
1446
  });
1088
1447
  }
1448
+ async supportedEfforts(scopeId) {
1449
+ const backend = this.getBackendDescriptorForScope(scopeId);
1450
+ const current = this.store.getChatSettings(scopeId)?.model;
1451
+ const models = await backend.adapter.listModels(scopeId);
1452
+ const selected = models.find(model => current ? model.id === current : model.isDefault);
1453
+ return selected?.supportedReasoningEfforts ?? backend.defaults?.supportedReasoningEfforts ?? ['low', 'medium', 'high'];
1454
+ }
1089
1455
  async handleEffortCommand(scopeId, args, locale) {
1090
- const target = args.trim().toLowerCase();
1091
- if (target === 'low' || target === 'medium' || target === 'high') {
1456
+ const supported = await this.supportedEfforts(scopeId);
1457
+ const target = args.trim();
1458
+ if (target) {
1459
+ const effort = target === 'default' ? null : target;
1460
+ if (effort && !supported.includes(effort)) {
1461
+ await this.sendMessage(scopeId, locale === 'zh' ? `当前模型不支持推理档位 \`${effort}\`。可用:default ${supported.join(' ')}` : `Unsupported reasoning effort \`${effort}\`. Available: default ${supported.join(' ')}`);
1462
+ return;
1463
+ }
1092
1464
  const settings = this.store.getChatSettings(scopeId);
1093
- if (target !== 'high' && settings?.serviceTier === 'boost') {
1465
+ if (effort !== 'high' && settings?.serviceTier === 'boost')
1094
1466
  this.store.setChatServiceTier(scopeId, null);
1095
- }
1096
- this.store.setChatEffort(scopeId, target);
1467
+ this.store.setChatEffort(scopeId, effort);
1097
1468
  this.syncCurrentBackendSettings(scopeId);
1098
- await this.sendMessage(scopeId, locale === 'zh' ? `✅ 思考深度已设置为: \`${target}\`` : `✅ Reasoning effort set to: \`${target}\``);
1469
+ await this.sendMessage(scopeId, locale === 'zh' ? `✅ 思考深度已设置为: \`${effort ?? 'default'}\`` : `✅ Reasoning effort set to: \`${effort ?? 'default'}\``);
1099
1470
  }
1100
1471
  else {
1101
- const current = this.store.getChatSettings(scopeId)?.reasoningEffort ?? 'high';
1102
- await this.sendMessage(scopeId, locale === 'zh'
1103
- ? `• **当前思考深度**: \`${current}\`\n\n用法: \`/effort <low|medium|high>\``
1104
- : `• **Current effort**: \`${current}\`\n\nUsage: \`/effort <low|medium|high>\``);
1472
+ const current = this.store.getChatSettings(scopeId)?.reasoningEffort ?? 'default';
1473
+ await this.sendMessage(scopeId, locale === 'zh' ? `当前思考深度: \`${current}\`\n可用:default ${supported.join(' ')}` : `Current effort: \`${current}\`\nAvailable: default ${supported.join(' ')}`);
1105
1474
  }
1106
1475
  }
1107
1476
  async handleBoostCommand(scopeId, args, locale) {
1477
+ if (this.getBackendDescriptorForScope(scopeId).defaults?.boost === false) {
1478
+ await this.sendMessage(scopeId, locale === 'zh' ? '当前后端不支持 Boost。' : 'Boost is not available on this backend.');
1479
+ return;
1480
+ }
1108
1481
  const isBoost = this.store.getChatSettings(scopeId)?.serviceTier === 'boost';
1109
1482
  const target = args.trim().toLowerCase();
1110
1483
  let nextBoost = !isBoost;
@@ -1143,6 +1516,15 @@ export class UnifiedChannelOrchestrator {
1143
1516
  }
1144
1517
  }
1145
1518
  async handleQueueCommand(event, args, locale) {
1519
+ const prompt = args.trim();
1520
+ if (prompt && prompt.toLowerCase() !== 'clear' && this.store.taskJournal.findReceipt(event, prompt))
1521
+ return;
1522
+ const task = prompt && prompt.toLowerCase() !== 'clear' ? this.captureTask(event, prompt, locale) : null;
1523
+ await this.scopeOperations.run(event.scopeId, () => this.handleQueueCommandNow(event, args, locale, task?.id));
1524
+ if (args.trim() && args.trim().toLowerCase() !== 'clear' && !this.stopped)
1525
+ await this.drainNextQueuedTurn(event, locale);
1526
+ }
1527
+ async handleQueueCommandNow(event, args, locale, taskId) {
1146
1528
  const scopeId = event.scopeId;
1147
1529
  if (!args.trim()) {
1148
1530
  const count = this.store.countQueuedTurnInputs(scopeId);
@@ -1152,15 +1534,33 @@ export class UnifiedChannelOrchestrator {
1152
1534
  return;
1153
1535
  }
1154
1536
  if (args.trim().toLowerCase() === 'clear') {
1155
- const cleared = this.store.cancelQueuedTurnInputs(scopeId);
1537
+ const cleared = this.cancelQueuedWork(scopeId);
1156
1538
  await this.sendMessage(scopeId, locale === 'zh'
1157
1539
  ? `🗑️ 已清空当前会话的 ${cleared} 条排队任务。`
1158
1540
  : `🗑️ Cleared ${cleared} queued tasks.`);
1159
1541
  return;
1160
1542
  }
1161
- await this.enqueuePromptTurn(event, args.trim(), locale);
1543
+ const binding = this.store.getBinding(scopeId);
1544
+ try {
1545
+ const task = taskId ? this.store.taskJournal.get(taskId) : null;
1546
+ const staged = event.attachments?.length ? await stageInboundAttachments(this.messaging, task?.request.cwd || binding?.cwd || this.config.defaultCwd, task?.request.threadId || binding?.threadId || 'default', event.attachments, this.logger) : [];
1547
+ if (staged.length !== event.attachments.length)
1548
+ throw new Error('Some attachments could not be saved; the task was not queued');
1549
+ await this.enqueuePromptTurn(event, args.trim(), locale, staged, taskId);
1550
+ }
1551
+ catch (error) {
1552
+ if (taskId)
1553
+ this.store.taskJournal.update(taskId, 'failed', { error: String(error) });
1554
+ await this.sendMessage(scopeId, `⚠️ ${String(error)}`);
1555
+ }
1162
1556
  }
1163
1557
  async handleSteerCommand(event, args, locale) {
1558
+ if (this.stopped || (args.trim() && this.store.taskJournal.findReceipt(event, args.trim())))
1559
+ return;
1560
+ const task = args.trim() ? this.captureTask(event, args.trim(), locale) : null;
1561
+ return this.scopeOperations.run(event.scopeId, () => this.handleSteerCommandNow(event, args, locale, task?.id));
1562
+ }
1563
+ async handleSteerCommandNow(event, args, locale, taskId) {
1164
1564
  const scopeId = event.scopeId;
1165
1565
  if (!args.trim()) {
1166
1566
  await this.sendMessage(scopeId, locale === 'zh' ? '用法: `/steer <消息>` (立即中断当前思考,带入新指令)' : 'Usage: `/steer <message>`');
@@ -1168,28 +1568,23 @@ export class UnifiedChannelOrchestrator {
1168
1568
  }
1169
1569
  const active = this.activeTurns.get(scopeId);
1170
1570
  if (active) {
1171
- active.execution.cancel();
1172
- if (active.typingTimer)
1173
- clearInterval(active.typingTimer);
1174
- if (active.flushTimer)
1175
- clearTimeout(active.flushTimer);
1176
- this.activeTurns.delete(scopeId);
1571
+ await this.cancelActiveTurn(scopeId);
1177
1572
  await this.sendMessage(scopeId, locale === 'zh'
1178
1573
  ? `⚡ 已中断前置任务,立即开始插话指令:\n> ${args.trim()}`
1179
1574
  : `⚡ Interrupted active turn, executing steer instruction:\n> ${args.trim()}`);
1180
1575
  }
1181
- await this.executeTurn(event, args.trim(), locale);
1576
+ await this.startPromptTurnNow(event, args.trim(), locale, { taskId });
1182
1577
  }
1183
1578
  async handleInterrupt(scopeId, locale) {
1579
+ return this.scopeOperations.run(scopeId, () => this.handleInterruptNow(scopeId, locale));
1580
+ }
1581
+ async handleInterruptNow(scopeId, locale) {
1184
1582
  const active = this.activeTurns.get(scopeId);
1185
1583
  const queuedCount = this.store.countQueuedTurnInputs(scopeId);
1186
1584
  if (active) {
1187
- active.execution.cancel();
1188
- if (active.flushTimer)
1189
- clearTimeout(active.flushTimer);
1190
- if (active.typingTimer)
1191
- clearInterval(active.typingTimer);
1192
- this.activeTurns.delete(scopeId);
1585
+ await this.cancelActiveTurn(scopeId);
1586
+ if (this.messaging.beginTaskPreview)
1587
+ return;
1193
1588
  let extraMsg = '';
1194
1589
  if (queuedCount > 0) {
1195
1590
  extraMsg = locale === 'zh'
@@ -1199,14 +1594,21 @@ export class UnifiedChannelOrchestrator {
1199
1594
  await this.sendMessage(scopeId, (locale === 'zh' ? '🛑 已发送中断请求。' : '🛑 Interrupt request sent.') + extraMsg);
1200
1595
  }
1201
1596
  else if (queuedCount > 0) {
1202
- const cleared = this.store.cancelQueuedTurnInputs(scopeId);
1597
+ const cleared = this.cancelQueuedWork(scopeId);
1203
1598
  await this.sendMessage(scopeId, locale === 'zh' ? `🛑 当前无运行中任务,已清空 ${cleared} 条排队任务。` : `🛑 Cleared ${cleared} queued tasks.`);
1204
1599
  }
1205
1600
  else {
1206
1601
  await this.sendMessage(scopeId, locale === 'zh' ? 'ℹ️ 当前没有正在执行或排队的任务。' : 'ℹ️ No active or queued tasks to interrupt.');
1207
1602
  }
1208
1603
  }
1209
- async handleNewSession(scopeId, locale, targetCwdInput) {
1604
+ async handleNewSession(scopeId, locale, cwdArg = '') {
1605
+ return this.scopeOperations.run(scopeId, () => this.handleNewSessionNow(scopeId, locale, cwdArg));
1606
+ }
1607
+ async handleNewSessionNow(scopeId, locale, targetCwdInput) {
1608
+ if (this.recoveryBlocksScope(scopeId)) {
1609
+ await this.sendMessage(scopeId, locale === 'zh' ? '先用 /recover 处理待确认或待发送的任务,再新建会话。' : 'Resolve the pending task with /recover before creating a session.');
1610
+ return;
1611
+ }
1210
1612
  const rawTarget = targetCwdInput?.trim();
1211
1613
  const binding = this.store.getBinding(scopeId);
1212
1614
  let cwd = binding?.cwd || this.config.defaultCwd;
@@ -1235,16 +1637,8 @@ export class UnifiedChannelOrchestrator {
1235
1637
  cwd = resolved;
1236
1638
  }
1237
1639
  // Cancel queued turn inputs
1238
- this.store.cancelQueuedTurnInputs(scopeId);
1239
- const active = this.activeTurns.get(scopeId);
1240
- if (active) {
1241
- active.execution.cancel();
1242
- if (active.typingTimer)
1243
- clearInterval(active.typingTimer);
1244
- if (active.flushTimer)
1245
- clearTimeout(active.flushTimer);
1246
- this.activeTurns.delete(scopeId);
1247
- }
1640
+ this.cancelQueuedWork(scopeId);
1641
+ await this.cancelActiveTurn(scopeId);
1248
1642
  // Reset thread binding to empty/ready state with the target cwd
1249
1643
  this.store.setBinding(scopeId, '', cwd);
1250
1644
  this.syncCurrentBackendSettings(scopeId);
@@ -1259,17 +1653,21 @@ export class UnifiedChannelOrchestrator {
1259
1653
  const isBusy = this.activeTurns.has(scopeId);
1260
1654
  const backendDesc = this.getBackendDescriptorForScope(scopeId);
1261
1655
  const adapter = backendDesc.adapter;
1262
- const customStatus = this.customUi?.renderCustomStatus ? await this.customUi.renderCustomStatus(scopeId, locale) : '';
1656
+ const ui = this.backends.ui(backendDesc.id) ?? this.customUi;
1657
+ const customStatus = await ui?.renderCustomStatus?.(scopeId, locale);
1263
1658
  const totalUsage = this.store.getCumulativeTokenUsage();
1264
1659
  const tokenLine = formatTokenUsageSummary(totalUsage, locale);
1265
1660
  const allUsages = this.store.getAllBackendTokenUsages();
1266
1661
  const breakdown = formatBackendTokenUsageBreakdown(allUsages);
1662
+ const usageText = backendDesc.defaults?.tokenUsage === false
1663
+ ? (locale === 'zh' ? '• **Token 消耗**: 当前后端未提供逐轮统计\n' : '• **Token usage**: Per-turn counts are not exposed by this backend\n')
1664
+ : `${tokenLine}${breakdown}\n`;
1267
1665
  const text = locale === 'zh'
1268
1666
  ? `📊 **${adapter.name} 运行状态**\n\n` +
1269
1667
  `• **当前引擎**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
1270
1668
  `• **状态**: ${isBusy ? '⚡ 正在执行任务' : '💤 空闲'}\n` +
1271
1669
  `• **当前模型**: \`${settings?.model || '默认'}\`\n` +
1272
- `${tokenLine}${breakdown}\n` +
1670
+ usageText +
1273
1671
  `• **绑定会话**: \`${binding?.threadId || '(新会话)'}\`\n` +
1274
1672
  `• **工作目录**: \`${binding?.cwd || this.config.defaultCwd}\`` +
1275
1673
  (customStatus ? `\n${customStatus}` : '')
@@ -1277,7 +1675,7 @@ export class UnifiedChannelOrchestrator {
1277
1675
  `• **Engine**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
1278
1676
  `• **State**: ${isBusy ? '⚡ Executing' : '💤 Idle'}\n` +
1279
1677
  `• **Model**: \`${settings?.model || 'default'}\`\n` +
1280
- `${tokenLine}${breakdown}\n` +
1678
+ usageText +
1281
1679
  `• **Thread**: \`${binding?.threadId || '(new)'}\`\n` +
1282
1680
  `• **Directory**: \`${binding?.cwd || this.config.defaultCwd}\`` +
1283
1681
  (customStatus ? `\n${customStatus}` : '');
@@ -1297,9 +1695,12 @@ export class UnifiedChannelOrchestrator {
1297
1695
  this.scheduleStalePanelDeletion(scopeId, msgId);
1298
1696
  }
1299
1697
  async sendSetupMenu(scopeId, locale, editMessageId) {
1698
+ const ui = this.backends.ui(this.getBackendDescriptorForScope(scopeId).id) ?? this.customUi;
1699
+ if (await ui?.renderSetupMenu?.(scopeId, locale, editMessageId))
1700
+ return;
1300
1701
  const settings = this.store.getChatSettings(scopeId);
1301
1702
  const mode = settings?.activeTurnMessageMode ?? 'queue';
1302
- const isBoost = settings?.serviceTier === 'boost';
1703
+ const isBoost = settings?.serviceTier === 'boost' && this.getBackendDescriptorForScope(scopeId).defaults?.boost !== false;
1303
1704
  const backendDesc = this.getBackendDescriptorForScope(scopeId);
1304
1705
  const adapter = backendDesc.adapter;
1305
1706
  const allBackends = await this.listBackends();
@@ -1310,19 +1711,19 @@ export class UnifiedChannelOrchestrator {
1310
1711
  `• **当前引擎**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
1311
1712
  `• **当前模型**: \`${settings?.model || '默认'}\`\n` +
1312
1713
  `• **思考深度**: \`${currentEffort || '默认'}\`\n` +
1313
- `• **Boost 增强**: ${isBoost ? '🚀 已开启' : '⚪ 已关闭'}\n` +
1714
+ (backendDesc.defaults?.boost !== false ? `• **Boost 增强**: ${isBoost ? '🚀 已开启' : '⚪ 已关闭'}\n` : '') +
1314
1715
  `• **运行中消息**: ${mode === 'steer' ? '⚡ 插话 (立即中断接管)' : '⏳ 排队 (完成后自动执行)'}\n\n` +
1315
1716
  `请选择要配置的项目:`
1316
1717
  : `⚙️ **${adapter.name} Setup Panel**\n\n` +
1317
1718
  `• **Engine**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
1318
1719
  `• **Model**: \`${settings?.model || 'default'}\`\n` +
1319
1720
  `• **Effort**: \`${currentEffort || 'default'}\`\n` +
1320
- `• **Boost**: ${isBoost ? '🚀 Enabled' : '⚪ Disabled'}\n` +
1721
+ (backendDesc.defaults?.boost !== false ? `• **Boost**: ${isBoost ? '🚀 Enabled' : '⚪ Disabled'}\n` : '') +
1321
1722
  `• **Active-Turn**: ${mode === 'steer' ? '⚡ Steer' : '⏳ Queue'}\n\n` +
1322
1723
  `Select setting to configure:`;
1323
1724
  const keyboard = [];
1324
1725
  // 1. Model choices (Codex style)
1325
- const models = await adapter.listModels();
1726
+ const models = await adapter.listModels(scopeId);
1326
1727
  const modelButtons = [
1327
1728
  {
1328
1729
  text: `${!currentModel || currentModel === 'default' ? '• ' : ''}${locale === 'zh' ? '默认模型' : 'Default'}`,
@@ -1341,13 +1742,8 @@ export class UnifiedChannelOrchestrator {
1341
1742
  keyboard.push(modelButtons.slice(i, i + 2));
1342
1743
  }
1343
1744
  // 2. Reasoning effort choices (Codex style)
1344
- const isAgy = backendDesc.engineType === 'antigravity' || backendDesc.id === 'antigravity';
1345
- const isCodex = backendDesc.engineType === 'codex' || backendDesc.id === 'codex';
1346
- const supportedEfforts = isAgy
1347
- ? ['low', 'medium', 'high']
1348
- : isCodex
1349
- ? ['low', 'medium', 'high', 'xhigh', 'max']
1350
- : ['low', 'medium', 'high'];
1745
+ const selectedModel = models.find(model => currentModel ? model.id === currentModel : model.isDefault);
1746
+ const supportedEfforts = selectedModel?.supportedReasoningEfforts ?? backendDesc.defaults?.supportedReasoningEfforts ?? ['low', 'medium', 'high'];
1351
1747
  const effortButtons = [
1352
1748
  {
1353
1749
  text: `${!currentEffort || currentEffort === 'default' ? '• ' : ''}${locale === 'zh' ? '默认深度' : 'Default'}`,
@@ -1363,18 +1759,18 @@ export class UnifiedChannelOrchestrator {
1363
1759
  }
1364
1760
  // 3. Boost & Active Mode row
1365
1761
  keyboard.push([
1366
- {
1367
- text: isBoost ? (locale === 'zh' ? '🚀 Boost: 开启' : '🚀 Boost: On') : (locale === 'zh' ? '⚪ Boost: 关闭' : '⚪ Boost: Off'),
1368
- callback_data: 'engine:setup:boost',
1369
- },
1762
+ ...(backendDesc.defaults?.boost !== false ? [{
1763
+ text: isBoost ? (locale === 'zh' ? '🚀 Boost: 开启' : '🚀 Boost: On') : (locale === 'zh' ? '⚪ Boost: 关闭' : '⚪ Boost: Off'),
1764
+ callback_data: 'engine:setup:boost',
1765
+ }] : []),
1370
1766
  {
1371
1767
  text: mode === 'steer' ? (locale === 'zh' ? '⚡ 插话模式' : '⚡ Steer') : (locale === 'zh' ? '⏳ 排队模式' : '⏳ Queue'),
1372
1768
  callback_data: 'engine:setup:active_mode',
1373
1769
  },
1374
1770
  ]);
1375
1771
  // 4. Custom rows (Account & History)
1376
- if (this.customUi?.renderCustomSetupRows) {
1377
- const customRows = await this.customUi.renderCustomSetupRows(scopeId, locale);
1772
+ if (ui?.renderCustomSetupRows) {
1773
+ const customRows = await ui.renderCustomSetupRows(scopeId, locale);
1378
1774
  keyboard.push(...customRows);
1379
1775
  }
1380
1776
  // 5. Session & Backend switcher row
@@ -1396,10 +1792,13 @@ export class UnifiedChannelOrchestrator {
1396
1792
  }
1397
1793
  }
1398
1794
  async sendModelsMenu(scopeId, locale, editMessageId) {
1795
+ const ui = this.backends.ui(this.getBackendDescriptorForScope(scopeId).id) ?? this.customUi;
1796
+ if (await ui?.renderModelsMenu?.(scopeId, locale, editMessageId))
1797
+ return;
1399
1798
  const settings = this.store.getChatSettings(scopeId);
1400
1799
  const currentModel = settings?.model;
1401
1800
  const adapter = this.getAdapterForScope(scopeId);
1402
- const models = await adapter.listModels();
1801
+ const models = await adapter.listModels(scopeId);
1403
1802
  const keyboard = [];
1404
1803
  for (let i = 0; i < models.length; i += 2) {
1405
1804
  const row = [];
@@ -1464,7 +1863,8 @@ export class UnifiedChannelOrchestrator {
1464
1863
  }
1465
1864
  async sendBackendMenu(scopeId, locale, editMessageId) {
1466
1865
  const backends = await this.listBackends();
1467
- const activeBackend = this.getBackendDescriptorForScope(scopeId);
1866
+ const activeId = this.store.getActiveBackend(scopeId) || this.defaultBackendId;
1867
+ const activeBackend = this.backends.get(activeId) ?? { id: activeId, name: `${activeId} (${locale === 'zh' ? '不可用' : 'unavailable'})`, engineType: activeId, adapter: this.adapter };
1468
1868
  const binding = this.store.getBinding(scopeId);
1469
1869
  const lines = [
1470
1870
  locale === 'zh' ? '🔌 **后端运行环境 (Backends & Engines)**' : '🔌 **Backends & Engines**',