@foxden-app/foxclaw 0.7.2 → 0.10.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 (71) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +129 -0
  3. package/README.md +22 -2
  4. package/README_EN.md +22 -2
  5. package/dist/antigravity/adapter.d.ts +17 -0
  6. package/dist/antigravity/adapter.js +256 -0
  7. package/dist/antigravity/auth.d.ts +121 -0
  8. package/dist/antigravity/auth.js +896 -0
  9. package/dist/antigravity/client.d.ts +32 -0
  10. package/dist/antigravity/client.js +238 -0
  11. package/dist/antigravity/controller.d.ts +96 -0
  12. package/dist/antigravity/controller.js +1922 -0
  13. package/dist/antigravity/conversations.d.ts +29 -0
  14. package/dist/antigravity/conversations.js +289 -0
  15. package/dist/antigravity/events.d.ts +101 -0
  16. package/dist/antigravity/events.js +114 -0
  17. package/dist/antigravity/runtime.d.ts +47 -0
  18. package/dist/antigravity/runtime.js +82 -0
  19. package/dist/auth/cross_node_sync.js +2 -2
  20. package/dist/channels/telegram/telegram_channel_adapter.d.ts +10 -3
  21. package/dist/channels/telegram/telegram_channel_adapter.js +2 -1
  22. package/dist/channels/weixin/weixin_channel_adapter.d.ts +5 -1
  23. package/dist/codex_app/adapter.d.ts +20 -0
  24. package/dist/codex_app/adapter.js +213 -0
  25. package/dist/codex_app/client.d.ts +2 -1
  26. package/dist/codex_app/client.js +47 -4
  27. package/dist/codex_app/force_takeover.d.ts +12 -0
  28. package/dist/codex_app/force_takeover.js +27 -0
  29. package/dist/config.d.ts +12 -0
  30. package/dist/config.js +42 -0
  31. package/dist/controller/controller.d.ts +15 -5
  32. package/dist/controller/controller.js +252 -20
  33. package/dist/core/attachments.d.ts +11 -0
  34. package/dist/core/attachments.js +56 -0
  35. package/dist/core/engine_spi.d.ts +76 -0
  36. package/dist/core/engine_spi.js +1 -0
  37. package/dist/core/orchestrator.d.ts +101 -0
  38. package/dist/core/orchestrator.js +1404 -0
  39. package/dist/core/stream_preview.d.ts +29 -0
  40. package/dist/core/stream_preview.js +146 -0
  41. package/dist/core/turn_queue.d.ts +19 -0
  42. package/dist/core/turn_queue.js +41 -0
  43. package/dist/i18n.d.ts +36 -6
  44. package/dist/i18n.js +81 -6
  45. package/dist/main.js +190 -20
  46. package/dist/opencode/adapter.d.ts +11 -0
  47. package/dist/opencode/adapter.js +168 -0
  48. package/dist/store/database.d.ts +41 -0
  49. package/dist/store/database.js +242 -43
  50. package/dist/store/token_usage.d.ts +23 -0
  51. package/dist/store/token_usage.js +61 -0
  52. package/dist/telegram/api.js +34 -1
  53. package/dist/telegram/bot_home.d.ts +3 -0
  54. package/dist/telegram/bot_home.js +107 -0
  55. package/dist/telegram/gateway.d.ts +5 -0
  56. package/dist/telegram/gateway.js +21 -0
  57. package/dist/types.d.ts +1 -0
  58. package/dist/update.d.ts +6 -0
  59. package/dist/update.js +78 -6
  60. package/dist/voice/target.js +2 -1
  61. package/dist/voice/tts.js +4 -2
  62. package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/02./347/273/237/344/270/200/351/200/232/351/201/223/350/260/203/345/272/246/345/231/250/344/270/216/345/217/257/346/217/222/346/213/224/345/274/225/346/223/216SPI/357/274/232FoxClaw/344/273/216/345/215/225/344/275/223/350/265/260/345/220/221/345/244/232Agent/345/272/225/345/272/247.md +192 -0
  63. package/docs/devlogs//347/254/254/344/270/200/344/272/272/347/247/260/347/211/210/02./347/257/207/344/272/214_/345/275/223/344/270/200/344/270/252/346/241/245/346/216/245/345/231/250/345/274/200/345/247/213/345/255/246/344/274/232/345/220/254/346/207/202/346/211/200/346/234/211Agent.md +45 -0
  64. package/docs/user-manual.md +11 -1
  65. package/docs/zh/2026-09-05-bot-home-migration.md +26 -0
  66. package/docs/zh/2026-09-05-reliability-review.md +53 -0
  67. package/docs/zh/troubleshooting.md +12 -0
  68. package/docs/zh/user-manual.md +13 -1
  69. package/package.json +2 -2
  70. package/scripts/force-takeover.py +118 -0
  71. package/scripts/force-takeover.test.py +122 -0
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { externalWriterControl } from '../codex_app/force_takeover.js';
5
6
  import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from '../voice/files.js';
6
7
  import { normalizeLocale, t } from '../i18n.js';
7
8
  import { chatGptAuthMetadataMatchesCandidateName, parseChatGptAuthMetadata, readChatGptAuthRecord, readChatGptAuthMetadata, } from '../auth/mirror.js';
@@ -25,6 +26,7 @@ import { normalizeAccessPreset, resolveAccessMode } from './access.js';
25
26
  import { diffObservedTurn, findLatestTurn, findLiveTurn } from './observer.js';
26
27
  import { applySessionLog, bootstrapSessionLog, splitJsonlChunk, } from './session_observer.js';
27
28
  import { renderActiveTurnStatus } from './status.js';
29
+ import { formatMetricTokenCount } from '../store/token_usage.js';
28
30
  import { writeRuntimeStatus } from '../runtime.js';
29
31
  const AUTH_DELETE_REASON_NEEDS_REPAIR = 'needs_repair';
30
32
  const RESTART_PREVIEW_RECOVERY_RETRY_DELAYS_MS = [3000, 7000, 15_000, 30_000, 45_000, 60_000, 60_000, 60_000];
@@ -64,6 +66,8 @@ const DYNAMIC_HELP_COMMANDS = [
64
66
  { key: 'quota', line: '/quota' },
65
67
  { key: 'update', line: '/update' },
66
68
  { key: 'login_device', line: '/login_device' },
69
+ { key: 'login_cancel', line: '/login_cancel' },
70
+ { key: 'cli', line: '/cli' },
67
71
  { key: 'threads_archived', line: '/threads archived [query]' },
68
72
  { key: 'open', line: '/open <n>' },
69
73
  { key: 'goal', line: '/goal [objective|pause|resume|done|budget <tokens|off>|clear confirm]' },
@@ -122,6 +126,9 @@ export class BridgeSessionCore {
122
126
  pendingUserInputs = new Map();
123
127
  pendingMcpElicitations = new Map();
124
128
  pendingLoginsByScope = new Map();
129
+ externalWriterControl = externalWriterControl;
130
+ forceTakeoversInProgress = new Set();
131
+ pendingForceTakeovers = new Map();
125
132
  pendingLoginScopesById = new Map();
126
133
  pendingAuthAddsByLoginId = new Map();
127
134
  latestTurnDiffs = new Map();
@@ -182,9 +189,7 @@ export class BridgeSessionCore {
182
189
  this.updateStatus();
183
190
  });
184
191
  this.bot.on('text', (event) => {
185
- void this.withLock(event.scopeId, async () => this.handleText(event)).catch((error) => {
186
- void this.handleAsyncError('telegram.text', error, event.scopeId);
187
- });
192
+ this.dispatchInboundLikeTelegramText(event);
188
193
  });
189
194
  this.bot.on('callback', (event) => {
190
195
  void this.handleCallback(event).catch((error) => {
@@ -197,10 +202,23 @@ export class BridgeSessionCore {
197
202
  * (used by the Weixin adapter).
198
203
  */
199
204
  dispatchInboundLikeTelegramText(event) {
200
- void this.withLock(event.scopeId, async () => this.handleText(event)).catch((error) => {
205
+ const command = event.attachments.length === 0 ? parseCommand(event.text) : null;
206
+ const name = command?.name;
207
+ // Recovery commands must remain reachable while a normal command is waiting.
208
+ // handleText still performs normal addressing and authorization checks.
209
+ const urgent = name && (['status', 'cli', 'login_cancel', 'interrupt'].includes(name)
210
+ || (name === 'auth' && command?.args[0] === 'sync' && ['status', 'events', 'trace'].includes(command.args[1] ?? 'status')));
211
+ const task = urgent ? this.handleText(event) : this.withLock(event.scopeId, async () => this.handleText(event));
212
+ void task.catch((error) => {
201
213
  void this.handleAsyncError('channel.text', error, event.scopeId);
202
214
  });
203
215
  }
216
+ hasPendingInteraction(scopeId) {
217
+ return this.pendingThreadRenames.has(scopeId)
218
+ || this.pendingThreadNewCwds.has(scopeId)
219
+ || this.pendingUserInputs.has(scopeId)
220
+ || this.pendingForceTakeovers.has(scopeId);
221
+ }
204
222
  /** Start Codex app-server transport and attach RPC listeners. */
205
223
  async startCodexApp() {
206
224
  this.stopping = false;
@@ -230,6 +248,15 @@ export class BridgeSessionCore {
230
248
  this.attachedThreads.clear();
231
249
  this.threadTokenUsageAlerts.clear();
232
250
  if (!this.stopping) {
251
+ if (!this.codexReconnectPending) {
252
+ for (const scopeId of new Set([...this.activeTurns.values()].filter(active => !active.isObserved).map(active => active.scopeId))) {
253
+ const locale = this.localeForChat(scopeId);
254
+ void this.sendMessage(scopeId, locale === 'zh'
255
+ ? 'Codex 连接中断,正在重连。任务结果尚未确认,请先用 /status 检查;也可用 /cli 从本机继续。'
256
+ : 'Codex disconnected; reconnecting. The task result is unconfirmed. Check /status or use /cli locally.')
257
+ .catch(error => this.logger.warn('codex.disconnect_notice_failed', { scopeId, error: toErrorMeta(error) }));
258
+ }
259
+ }
233
260
  this.codexReconnectPending = true;
234
261
  this.pauseAppSnapshotWatchers();
235
262
  }
@@ -304,12 +331,13 @@ export class BridgeSessionCore {
304
331
  this.updateStatus();
305
332
  }
306
333
  getRuntimeStatus() {
334
+ const appServer = typeof this.app.getServerStatus === 'function' ? this.app.getServerStatus() : null;
307
335
  return {
308
336
  running: true,
309
- connected: this.app.isConnected(),
310
- userAgent: this.app.getUserAgent(),
337
+ connected: typeof this.app.isConnected === 'function' ? this.app.isConnected() : false,
338
+ userAgent: typeof this.app.getUserAgent === 'function' ? this.app.getUserAgent() : null,
311
339
  codexHome: this.config.codexHome ?? path.join(os.homedir(), '.codex'),
312
- codexAppServer: this.app.getServerStatus(),
340
+ ...(appServer ? { codexAppServer: appServer } : {}),
313
341
  botUsername: this.botUsername,
314
342
  currentBindings: this.store.countBindings(),
315
343
  pendingApprovals: this.store.countPendingApprovals(),
@@ -539,6 +567,9 @@ export class BridgeSessionCore {
539
567
  if (codexUpdateLine) {
540
568
  lines.push(t(locale, 'status_last_codex_update', { value: codexUpdateLine }));
541
569
  }
570
+ if (lastUpdate.agyUpdate) {
571
+ lines.push(locale === 'zh' ? `Antigravity CLI:${lastUpdate.agyUpdate}` : `Antigravity CLI: ${lastUpdate.agyUpdate}`);
572
+ }
542
573
  }
543
574
  else {
544
575
  lines.push(t(locale, 'status_last_update_none'));
@@ -582,6 +613,20 @@ export class BridgeSessionCore {
582
613
  await this.handleLoginCancelCommand(scopeId, locale, args);
583
614
  return;
584
615
  }
616
+ case 'cli': {
617
+ const binding = this.store.getBinding(scopeId);
618
+ const server = this.app.getServerStatus();
619
+ if (!server.running || !server.port) {
620
+ await this.sendMessage(scopeId, locale === 'zh' ? 'Codex 服务不可用,请在本机检查 foxclaw status。' : 'Codex server unavailable. Check foxclaw status locally.');
621
+ return;
622
+ }
623
+ const threadArg = binding ? ` ${JSON.stringify(binding.threadId)}` : '';
624
+ await this.sendMessage(scopeId, (locale === 'zh'
625
+ ? '在运行桥的这台机器上执行,进入同一个 Codex 服务后可中断或继续会话:\n'
626
+ : 'Run on the bridge host to interrupt or continue in the same Codex server:\n')
627
+ + `codex resume --remote ws://127.0.0.1:${server.port}${threadArg}`);
628
+ return;
629
+ }
585
630
  case 'logout': {
586
631
  await this.handleLogoutCommand(scopeId, locale, args);
587
632
  return;
@@ -1072,6 +1117,21 @@ export class BridgeSessionCore {
1072
1117
  async handleCallback(event) {
1073
1118
  const scopeId = event.scopeId;
1074
1119
  const locale = this.localeForChat(scopeId, event.languageCode);
1120
+ if (this.forceTakeoversInProgress.has(scopeId)) {
1121
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_busy'));
1122
+ return;
1123
+ }
1124
+ const forceTakeoverMatch = /^takeover:(confirm|cancel):([a-f0-9]+)$/.exec(event.data);
1125
+ if (forceTakeoverMatch) {
1126
+ await this.handleForceTakeoverCallback(event, locale, forceTakeoverMatch[1], forceTakeoverMatch[2]);
1127
+ return;
1128
+ }
1129
+ const loginCancelMatch = /^login:cancel:(.+)$/.exec(event.data);
1130
+ if (loginCancelMatch) {
1131
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'button_cancel'));
1132
+ await this.handleLoginCancelCommand(scopeId, locale, [loginCancelMatch[1]]);
1133
+ return;
1134
+ }
1075
1135
  const interruptMatch = /^turn:interrupt:(.+)$/.exec(event.data);
1076
1136
  if (interruptMatch) {
1077
1137
  await this.handleTurnInterruptCallback(event, interruptMatch[1], locale);
@@ -3432,7 +3492,9 @@ export class BridgeSessionCore {
3432
3492
  this.coordinator.statusUpdated(status);
3433
3493
  return;
3434
3494
  }
3435
- writeRuntimeStatus(this.config.statusPath, status);
3495
+ if (this.config.statusPath) {
3496
+ writeRuntimeStatus(this.config.statusPath, status);
3497
+ }
3436
3498
  }
3437
3499
  async sendMessage(scopeId, text, inlineKeyboard) {
3438
3500
  return this.sendRichMarkdownMessage(scopeId, text, inlineKeyboard);
@@ -3654,7 +3716,9 @@ export class BridgeSessionCore {
3654
3716
  if (!scopeId)
3655
3717
  return;
3656
3718
  try {
3657
- await this.sendMessage(scopeId, t(this.localeForChat(scopeId), 'bridge_error', { error: formatUserError(error) }));
3719
+ const locale = this.localeForChat(scopeId);
3720
+ const hint = isThreadActiveWriterError(error) ? `\n${t(locale, 'force_takeover_hint')}` : '';
3721
+ await this.sendMessage(scopeId, t(locale, 'bridge_error', { error: formatUserError(error) }) + hint);
3658
3722
  }
3659
3723
  catch (notifyError) {
3660
3724
  this.logger.error('telegram.error_notification_failed', { error: toErrorMeta(notifyError), scopeId });
@@ -3887,7 +3951,7 @@ export class BridgeSessionCore {
3887
3951
  }
3888
3952
  }
3889
3953
  for (const active of [...this.activeTurns.values()]) {
3890
- if (recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
3954
+ if (active.isObserved || recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
3891
3955
  continue;
3892
3956
  }
3893
3957
  try {
@@ -3900,6 +3964,10 @@ export class BridgeSessionCore {
3900
3964
  turnId: active.turnId,
3901
3965
  error: toErrorMeta(error),
3902
3966
  });
3967
+ await this.sendMessage(active.scopeId, this.localeForChat(active.scopeId) === 'zh'
3968
+ ? '连接已恢复,但无法确认原任务状态。任务未自动重发,请用 /cli 检查原会话后再继续。'
3969
+ : 'Connected again, but the original task state could not be confirmed. It was not resent. Use /cli to inspect the session.')
3970
+ .catch(notifyError => this.logger.warn('codex.recovery_notice_failed', { error: toErrorMeta(notifyError) }));
3903
3971
  }
3904
3972
  }
3905
3973
  await this.recoverQueuedTurns();
@@ -4246,6 +4314,10 @@ export class BridgeSessionCore {
4246
4314
  }
4247
4315
  }
4248
4316
  async handleTakeoverCommand(event, locale, args) {
4317
+ if (args[0] === '--force') {
4318
+ await this.prepareForceTakeover(event, locale, args.slice(1).join(' ').trim());
4319
+ return;
4320
+ }
4249
4321
  const scopeId = event.scopeId;
4250
4322
  const nextPrompt = args.join(' ').trim();
4251
4323
  if (!nextPrompt) {
@@ -4263,10 +4335,113 @@ export class BridgeSessionCore {
4263
4335
  await this.requestInterrupt(active);
4264
4336
  }
4265
4337
  await this.sendMessage(scopeId, t(locale, 'interrupt_requested_waiting'));
4266
- await active.completion;
4338
+ let timer;
4339
+ try {
4340
+ await Promise.race([
4341
+ active.completion,
4342
+ new Promise((_, reject) => {
4343
+ timer = setTimeout(() => reject(new Error(locale === 'zh'
4344
+ ? '等待中断完成超时,尚未发送新任务。请用 /status 检查,或用 /cli 从终端恢复。'
4345
+ : 'Interrupt confirmation timed out. No replacement task was sent. Check /status or use /cli to recover.')), 30_000);
4346
+ }),
4347
+ ]);
4348
+ }
4349
+ finally {
4350
+ clearTimeout(timer);
4351
+ }
4267
4352
  }
4268
4353
  await this.startBoundTurnFromEvent(event, locale, nextPrompt);
4269
4354
  }
4355
+ async prepareForceTakeover(event, locale, prompt) {
4356
+ const scopeId = event.scopeId;
4357
+ this.pendingForceTakeovers.delete(scopeId);
4358
+ if (!prompt) {
4359
+ await this.sendMessage(scopeId, t(locale, 'usage_takeover'));
4360
+ return;
4361
+ }
4362
+ if (!scopeId.startsWith('telegram:') || event.userId !== this.config.tgAllowedUserId) {
4363
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_trusted_telegram_only'));
4364
+ return;
4365
+ }
4366
+ const binding = this.store.getBinding(scopeId);
4367
+ if (!binding) {
4368
+ await this.sendMessage(scopeId, t(locale, 'watch_no_thread_bound'));
4369
+ return;
4370
+ }
4371
+ const home = this.config.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
4372
+ try {
4373
+ const writer = await this.externalWriterControl.inspect(home, binding.threadId);
4374
+ const id = crypto.randomBytes(8).toString('hex');
4375
+ this.pendingForceTakeovers.set(scopeId, {
4376
+ id, expiresAt: Date.now() + 60_000, event, threadId: binding.threadId, home, prompt, writer,
4377
+ });
4378
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_confirm', {
4379
+ threadId: binding.threadId, pid: String(writer.pid), cwd: writer.cwd, prompt,
4380
+ }), [[
4381
+ { text: t(locale, 'button_force_takeover'), callback_data: `takeover:confirm:${id}` },
4382
+ { text: t(locale, 'button_cancel'), callback_data: `takeover:cancel:${id}` },
4383
+ ]]);
4384
+ }
4385
+ catch (error) {
4386
+ this.pendingForceTakeovers.delete(scopeId);
4387
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
4388
+ }
4389
+ }
4390
+ async handleForceTakeoverCallback(event, locale, action, id) {
4391
+ const scopeId = event.scopeId;
4392
+ const pending = this.pendingForceTakeovers.get(scopeId);
4393
+ if (!pending || pending.id !== id || pending.event.userId !== event.userId
4394
+ || event.userId !== this.config.tgAllowedUserId || pending.expiresAt <= Date.now()) {
4395
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_expired'));
4396
+ return;
4397
+ }
4398
+ // Claim before any await: duplicate callbacks cannot stop or submit twice.
4399
+ this.pendingForceTakeovers.delete(scopeId);
4400
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, action === 'cancel' ? 'button_cancel' : 'button_force_takeover'));
4401
+ if (action === 'cancel') {
4402
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_cancelled'));
4403
+ return;
4404
+ }
4405
+ await this.withLock(scopeId, async () => {
4406
+ if (pending.expiresAt <= Date.now() || this.store.getBinding(scopeId)?.threadId !== pending.threadId) {
4407
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_expired'));
4408
+ return;
4409
+ }
4410
+ if (this.externalAuthValidationInProgress) {
4411
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
4412
+ return;
4413
+ }
4414
+ this.forceTakeoversInProgress.add(scopeId);
4415
+ this.turnStartInProgress += 1;
4416
+ try {
4417
+ try {
4418
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_stopping', { pid: String(pending.writer.pid) }));
4419
+ await this.externalWriterControl.stop(pending.home, pending.threadId, pending.writer);
4420
+ this.logger.warn('codex.external_writer_stopped', { scopeId, threadId: pending.threadId, pid: pending.writer.pid });
4421
+ await this.stopWatchingScopeThread(scopeId);
4422
+ this.attachedThreads.delete(attachedThreadKey(scopeId, pending.threadId));
4423
+ // Never replace a missing thread with an unrelated new conversation.
4424
+ const binding = this.store.getBinding(scopeId);
4425
+ if (binding?.threadId !== pending.threadId)
4426
+ throw new Error(t(locale, 'force_takeover_expired'));
4427
+ await this.ensureThreadReady(scopeId, binding, { recoverMissingThread: false });
4428
+ }
4429
+ catch (error) {
4430
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
4431
+ return;
4432
+ }
4433
+ this.store.cancelQueuedTurnInputs(scopeId);
4434
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_acquired', { threadId: pending.threadId }));
4435
+ // Normal turn-start error handling preserves ambiguous submission failures;
4436
+ // do not automatically retry a possibly accepted prompt.
4437
+ await this.startBoundTurnFromEvent(pending.event, locale, pending.prompt);
4438
+ }
4439
+ finally {
4440
+ this.forceTakeoversInProgress.delete(scopeId);
4441
+ this.turnStartInProgress -= 1;
4442
+ }
4443
+ });
4444
+ }
4270
4445
  async handleQueueCommand(event, locale, args) {
4271
4446
  const scopeId = event.scopeId;
4272
4447
  const nextPrompt = args.join(' ').trim();
@@ -4882,6 +5057,9 @@ export class BridgeSessionCore {
4882
5057
  const rows = [
4883
5058
  ['FoxClaw', `${status.fromVersion} -> ${status.toVersion ?? t(status.locale, 'unknown')}`, status.locale === 'zh' ? '升级完成,服务已重启' : 'Updated; service restarted'],
4884
5059
  ['Codex CLI', formatSelfUpdateVersionTransition(status.codexFromVersion, status.codexToVersion, status.locale), formatCodexUpdateState(status)],
5060
+ ...(status.agyFromVersion || status.agyUpdate ? [
5061
+ ['Antigravity (AGY)', formatSelfUpdateVersionTransition(status.agyFromVersion ?? null, status.agyToVersion ?? null, status.locale), status.agyUpdate ?? (status.locale === 'zh' ? '已检查更新' : 'Checked')]
5062
+ ] : []),
4885
5063
  [status.locale === 'zh' ? '集群广播' : 'Cluster broadcast', status.toVersion ?? t(status.locale, 'unknown'), broadcastLine],
4886
5064
  ];
4887
5065
  const notes = status.releaseNotes?.filter(note => note.trim()) ?? [];
@@ -4899,6 +5077,7 @@ export class BridgeSessionCore {
4899
5077
  telegramBold(title),
4900
5078
  escapeTelegramHtml(foxclawResult),
4901
5079
  escapeTelegramHtml(codexUpdateLine ?? (status.locale === 'zh' ? 'Codex CLI:未执行升级。' : 'Codex CLI: not updated.')),
5080
+ ...(status.agyUpdate ? [escapeTelegramHtml(`Antigravity CLI:${status.agyUpdate}`)] : []),
4902
5081
  escapeTelegramHtml(broadcastLine),
4903
5082
  releaseNotes ? escapeTelegramHtml(releaseNotes) : '',
4904
5083
  ].filter(Boolean).join('\n');
@@ -5376,7 +5555,7 @@ export class BridgeSessionCore {
5376
5555
  t(locale, 'login_code', { value: login.userCode }),
5377
5556
  t(locale, 'login_id', { value: login.loginId }),
5378
5557
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5379
- ].join('\n'));
5558
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5380
5559
  }
5381
5560
  catch (error) {
5382
5561
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5419,7 +5598,7 @@ export class BridgeSessionCore {
5419
5598
  t(locale, 'login_code', { value: login.userCode }),
5420
5599
  t(locale, 'login_id', { value: login.loginId }),
5421
5600
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5422
- ].join('\n'));
5601
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5423
5602
  }
5424
5603
  catch (error) {
5425
5604
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5584,6 +5763,11 @@ export class BridgeSessionCore {
5584
5763
  }
5585
5764
  }
5586
5765
  async handleLoginDeviceCommand(scopeId, locale) {
5766
+ const pendingLoginId = this.pendingLoginsByScope.get(scopeId);
5767
+ if (pendingLoginId) {
5768
+ await this.sendMessage(scopeId, t(locale, 'login_cancel_hint', { value: pendingLoginId }), this.loginCancelKeyboard(pendingLoginId, locale));
5769
+ return;
5770
+ }
5587
5771
  const login = await this.app.startDeviceLogin();
5588
5772
  const oldLoginId = this.pendingLoginsByScope.get(scopeId);
5589
5773
  if (oldLoginId) {
@@ -5598,24 +5782,41 @@ export class BridgeSessionCore {
5598
5782
  t(locale, 'login_code', { value: login.userCode }),
5599
5783
  t(locale, 'login_id', { value: login.loginId }),
5600
5784
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5601
- ].join('\n'));
5785
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5786
+ }
5787
+ loginCancelKeyboard(loginId, locale) {
5788
+ return [[{ text: t(locale, 'button_cancel'), callback_data: `login:cancel:${loginId}` }]];
5602
5789
  }
5603
5790
  async handleLoginCancelCommand(scopeId, locale, args) {
5604
5791
  const loginId = args[0]?.trim() || this.pendingLoginsByScope.get(scopeId) || null;
5605
- if (!loginId) {
5792
+ if (!loginId || this.pendingLoginsByScope.get(scopeId) !== loginId) {
5606
5793
  await this.sendMessage(scopeId, t(locale, 'login_cancel_no_pending'));
5607
5794
  return;
5608
5795
  }
5609
5796
  const pendingAuthAdd = this.pendingAuthAddsByLoginId.get(loginId) ?? null;
5610
- await this.app.cancelLogin(loginId);
5797
+ // Claim cancellation before awaiting RPC: completion notifications may arrive first.
5611
5798
  this.pendingLoginsByScope.delete(scopeId);
5612
5799
  this.pendingLoginScopesById.delete(loginId);
5613
5800
  this.pendingAuthAddsByLoginId.delete(loginId);
5801
+ let cancelError;
5802
+ try {
5803
+ await this.app.cancelLogin(loginId);
5804
+ }
5805
+ catch (error) {
5806
+ cancelError = error;
5807
+ this.logger.warn('codex.login_cancel_failed', { error: toErrorMeta(error) });
5808
+ }
5614
5809
  if (pendingAuthAdd) {
5615
5810
  await this.restorePendingAuthAdd(pendingAuthAdd);
5616
5811
  await this.sendMessage(scopeId, t(locale, 'auth_add_cancelled'));
5617
5812
  return;
5618
5813
  }
5814
+ if (cancelError) {
5815
+ await this.sendMessage(scopeId, locale === 'zh'
5816
+ ? '已退出本地登录流程,但未能确认服务端取消。请勿继续使用旧验证码;可重新登录或用 /codex_restart 重置登录服务。'
5817
+ : 'Local login flow cleared, but server cancellation is unconfirmed. Do not use the old code; start a new login or use /codex_restart.');
5818
+ return;
5819
+ }
5619
5820
  await this.sendMessage(scopeId, t(locale, 'login_cancelled'));
5620
5821
  }
5621
5822
  async handleLogoutCommand(scopeId, locale, args) {
@@ -6162,6 +6363,20 @@ export class BridgeSessionCore {
6162
6363
  await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
6163
6364
  }
6164
6365
  }
6366
+ async refreshRepairedAuthChoice(event, record, candidateName, locale) {
6367
+ const state = await this.listCodexAuthState();
6368
+ if (state.candidates.find(candidate => candidate.name === candidateName)?.state === 'needs_repair')
6369
+ return false;
6370
+ record.candidates = state.candidates;
6371
+ record.createdAt = Date.now();
6372
+ clampCodexAuthListOffset(record);
6373
+ await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh'
6374
+ ? '授权状态已变化,已刷新面板。' : 'Auth state changed; panel refreshed.');
6375
+ if (record.messageId !== null) {
6376
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
6377
+ }
6378
+ return true;
6379
+ }
6165
6380
  async handleAuthRepairMenuCallback(event, localId, index, locale) {
6166
6381
  const record = this.pendingAuthChoiceLists.get(localId);
6167
6382
  if (!record) {
@@ -6177,6 +6392,8 @@ export class BridgeSessionCore {
6177
6392
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6178
6393
  return;
6179
6394
  }
6395
+ if (await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6396
+ return;
6180
6397
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
6181
6398
  if (record.messageId !== null) {
6182
6399
  await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
@@ -6197,6 +6414,8 @@ export class BridgeSessionCore {
6197
6414
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6198
6415
  return;
6199
6416
  }
6417
+ if (action !== 'cancel' && await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6418
+ return;
6200
6419
  if (action === 'cancel') {
6201
6420
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
6202
6421
  const state = await this.listCodexAuthState();
@@ -6847,11 +7066,11 @@ export class BridgeSessionCore {
6847
7066
  events: formatTokenCount(stats.usageEvents),
6848
7067
  }),
6849
7068
  t(locale, 'status_codex_local_tokens', {
6850
- total: formatTokenCount(stats.totals.totalTokens),
6851
- input: formatTokenCount(stats.totals.inputTokens),
7069
+ total: formatCodexTokenCountWithMetric(stats.totals.totalTokens),
7070
+ input: formatCodexTokenCountWithMetric(stats.totals.inputTokens),
6852
7071
  visible: formatTokenCount(Math.max(0, stats.totals.outputTokens - stats.totals.reasoningOutputTokens)),
6853
- output: formatTokenCount(stats.totals.outputTokens),
6854
- cached: formatTokenCount(stats.totals.cachedInputTokens),
7072
+ output: formatCodexTokenCountWithMetric(stats.totals.outputTokens),
7073
+ cached: formatCodexTokenCountWithMetric(stats.totals.cachedInputTokens),
6855
7074
  reasoning: formatTokenCount(stats.totals.reasoningOutputTokens),
6856
7075
  }),
6857
7076
  ...this.formatCodexLocalResponseThroughputStatusLines(locale, stats),
@@ -6920,6 +7139,13 @@ export class BridgeSessionCore {
6920
7139
  this.localUsageCache = snapshot;
6921
7140
  this.localUsageCacheLoaded = true;
6922
7141
  await writeCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath(), snapshot);
7142
+ this.store.setBackendCumulativeTokenUsage({
7143
+ inputTokens: stats.totals.inputTokens,
7144
+ outputTokens: stats.totals.outputTokens,
7145
+ cachedTokens: stats.totals.cachedInputTokens,
7146
+ totalTokens: stats.totals.totalTokens,
7147
+ turnsCount: stats.turns,
7148
+ }, 'codex');
6923
7149
  }
6924
7150
  codexLocalUsageSnapshotPath() {
6925
7151
  return path.join(path.dirname(this.config.statusPath), this.runtimeSnapshotFilename(CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME));
@@ -11829,6 +12055,12 @@ function formatTokenCount(value) {
11829
12055
  }
11830
12056
  return Math.round(value).toLocaleString('en-US');
11831
12057
  }
12058
+ function formatCodexTokenCountWithMetric(value) {
12059
+ if (value >= 1_000_000) {
12060
+ return `${formatMetricTokenCount(value)} (${formatTokenCount(value)})`;
12061
+ }
12062
+ return formatTokenCount(value);
12063
+ }
11832
12064
  function formatLocalTimestamp(seconds) {
11833
12065
  const date = new Date(seconds * 1000);
11834
12066
  const pad = (value) => String(value).padStart(2, '0');
@@ -0,0 +1,11 @@
1
+ import type { Logger } from '../logger.js';
2
+ import { type StagedTelegramAttachment, type TelegramInboundAttachment } from '../telegram/media.js';
3
+ export interface TelegramFileDownloader {
4
+ getFile(fileId: string): Promise<{
5
+ file_id: string;
6
+ file_path?: string;
7
+ file_size?: number;
8
+ }>;
9
+ downloadResolvedFile(filePath: string, destination: string): Promise<unknown>;
10
+ }
11
+ export declare function stageInboundAttachments(downloader: TelegramFileDownloader, cwd: string, threadId: string, attachments: readonly TelegramInboundAttachment[], logger?: Logger): Promise<StagedTelegramAttachment[]>;
@@ -0,0 +1,56 @@
1
+ import fsPromises from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { planAttachmentStoragePath, isNativeImageAttachment, TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, } from '../telegram/media.js';
4
+ export async function stageInboundAttachments(downloader, cwd, threadId, attachments, logger) {
5
+ const staged = [];
6
+ for (const attachment of attachments) {
7
+ try {
8
+ if (attachment.localPath) {
9
+ const planned = planAttachmentStoragePath(cwd, threadId, attachment, path.basename(attachment.localPath));
10
+ await fsPromises.mkdir(path.dirname(planned.localPath), { recursive: true });
11
+ await fsPromises.copyFile(attachment.localPath, planned.localPath);
12
+ const stat = await fsPromises.stat(planned.localPath);
13
+ const resolvedSize = stat.size;
14
+ staged.push({
15
+ ...attachment,
16
+ fileName: planned.fileName,
17
+ fileSize: resolvedSize,
18
+ localPath: planned.localPath,
19
+ relativePath: planned.relativePath,
20
+ nativeImage: isNativeImageAttachment(attachment),
21
+ });
22
+ continue;
23
+ }
24
+ const remoteFile = await downloader.getFile(attachment.fileId);
25
+ const resolvedSize = attachment.fileSize ?? remoteFile.file_size ?? null;
26
+ if (resolvedSize !== null && resolvedSize > TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES) {
27
+ logger?.warn('attachments.too_large', {
28
+ fileId: attachment.fileId,
29
+ size: resolvedSize,
30
+ });
31
+ continue;
32
+ }
33
+ if (!remoteFile.file_path) {
34
+ continue;
35
+ }
36
+ const planned = planAttachmentStoragePath(cwd, threadId, attachment, remoteFile.file_path);
37
+ await fsPromises.mkdir(path.dirname(planned.localPath), { recursive: true });
38
+ await downloader.downloadResolvedFile(remoteFile.file_path, planned.localPath);
39
+ staged.push({
40
+ ...attachment,
41
+ fileName: planned.fileName,
42
+ fileSize: resolvedSize,
43
+ localPath: planned.localPath,
44
+ relativePath: planned.relativePath,
45
+ nativeImage: isNativeImageAttachment(attachment),
46
+ });
47
+ }
48
+ catch (error) {
49
+ logger?.warn('attachments.download_failed', {
50
+ fileId: attachment.fileId,
51
+ error: error instanceof Error ? error.message : String(error),
52
+ });
53
+ }
54
+ }
55
+ return staged;
56
+ }
@@ -0,0 +1,76 @@
1
+ import type { StagedTelegramAttachment } from '../telegram/media.js';
2
+ import type { AppLocale } from '../types.js';
3
+ export interface EngineModel {
4
+ id: string;
5
+ name: string;
6
+ description?: string | undefined;
7
+ isDefault?: boolean | undefined;
8
+ }
9
+ export interface EngineTurnRequest {
10
+ scopeId: string;
11
+ prompt: string;
12
+ stagedAttachments?: StagedTelegramAttachment[] | undefined;
13
+ threadId: string | null;
14
+ cwd: string;
15
+ model: string;
16
+ effort?: string | null | undefined;
17
+ serviceTier?: string | null | undefined;
18
+ locale: AppLocale;
19
+ }
20
+ export interface EngineToolEvent {
21
+ name: string;
22
+ args?: Record<string, unknown> | string | undefined;
23
+ output?: string | undefined;
24
+ status?: 'running' | 'completed' | 'failed' | undefined;
25
+ stepIndex?: number | undefined;
26
+ summary?: string | undefined;
27
+ }
28
+ export interface EngineTurnResult {
29
+ kind: 'result';
30
+ status: 'SUCCESS' | 'ERROR' | 'INTERRUPTED';
31
+ response: string;
32
+ error?: string | undefined;
33
+ conversationId: string | null;
34
+ usage?: {
35
+ inputTokens?: number | undefined;
36
+ outputTokens?: number | undefined;
37
+ cachedTokens?: number | undefined;
38
+ totalTokens?: number | undefined;
39
+ } | undefined;
40
+ }
41
+ export interface EngineTurnExecution {
42
+ turnId?: string | undefined;
43
+ cancel(): void;
44
+ waitForResult(): Promise<EngineTurnResult | null>;
45
+ on(event: 'delta', listener: (text: string) => void): void;
46
+ on(event: 'tool', listener: (tool: EngineToolEvent) => void): void;
47
+ on(event: 'result', listener: (result: EngineTurnResult) => void): void;
48
+ on(event: 'error', listener: (error: Error) => void): void;
49
+ on(event: 'exit', listener: (code: number | null) => void): void;
50
+ }
51
+ export interface EngineTurnErrorContext {
52
+ error: string;
53
+ request: EngineTurnRequest;
54
+ retryCount: number;
55
+ retryTurn: (retryCount: number) => Promise<void>;
56
+ sendMessage: (text: string) => Promise<number>;
57
+ editMessage: (messageId: number, text: string) => Promise<void>;
58
+ }
59
+ export interface IEngineAdapter {
60
+ readonly id: string;
61
+ readonly name: string;
62
+ listModels(): Promise<EngineModel[]>;
63
+ executeTurn(request: EngineTurnRequest): EngineTurnExecution;
64
+ preflightTurn?(request: EngineTurnRequest): Promise<void>;
65
+ handleTurnError?(context: EngineTurnErrorContext): Promise<boolean>;
66
+ }
67
+ export interface BackendDescriptor {
68
+ id: string;
69
+ name: string;
70
+ engineType: string;
71
+ adapter: IEngineAdapter;
72
+ account?: string | undefined;
73
+ details?: string | undefined;
74
+ isDefault?: boolean | undefined;
75
+ onSelect?: (scopeId: string) => Promise<void>;
76
+ }
@@ -0,0 +1 @@
1
+ export {};