@foxden-app/foxclaw 0.7.1 → 0.7.3

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.
@@ -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';
@@ -64,6 +65,8 @@ const DYNAMIC_HELP_COMMANDS = [
64
65
  { key: 'quota', line: '/quota' },
65
66
  { key: 'update', line: '/update' },
66
67
  { key: 'login_device', line: '/login_device' },
68
+ { key: 'login_cancel', line: '/login_cancel' },
69
+ { key: 'cli', line: '/cli' },
67
70
  { key: 'threads_archived', line: '/threads archived [query]' },
68
71
  { key: 'open', line: '/open <n>' },
69
72
  { key: 'goal', line: '/goal [objective|pause|resume|done|budget <tokens|off>|clear confirm]' },
@@ -122,6 +125,9 @@ export class BridgeSessionCore {
122
125
  pendingUserInputs = new Map();
123
126
  pendingMcpElicitations = new Map();
124
127
  pendingLoginsByScope = new Map();
128
+ externalWriterControl = externalWriterControl;
129
+ forceTakeoversInProgress = new Set();
130
+ pendingForceTakeovers = new Map();
125
131
  pendingLoginScopesById = new Map();
126
132
  pendingAuthAddsByLoginId = new Map();
127
133
  latestTurnDiffs = new Map();
@@ -182,9 +188,7 @@ export class BridgeSessionCore {
182
188
  this.updateStatus();
183
189
  });
184
190
  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
- });
191
+ this.dispatchInboundLikeTelegramText(event);
188
192
  });
189
193
  this.bot.on('callback', (event) => {
190
194
  void this.handleCallback(event).catch((error) => {
@@ -197,7 +201,14 @@ export class BridgeSessionCore {
197
201
  * (used by the Weixin adapter).
198
202
  */
199
203
  dispatchInboundLikeTelegramText(event) {
200
- void this.withLock(event.scopeId, async () => this.handleText(event)).catch((error) => {
204
+ const command = event.attachments.length === 0 ? parseCommand(event.text) : null;
205
+ const name = command?.name;
206
+ // Recovery commands must remain reachable while a normal command is waiting.
207
+ // handleText still performs normal addressing and authorization checks.
208
+ const urgent = name && (['status', 'cli', 'login_cancel', 'interrupt'].includes(name)
209
+ || (name === 'auth' && command?.args[0] === 'sync' && ['status', 'events', 'trace'].includes(command.args[1] ?? 'status')));
210
+ const task = urgent ? this.handleText(event) : this.withLock(event.scopeId, async () => this.handleText(event));
211
+ void task.catch((error) => {
201
212
  void this.handleAsyncError('channel.text', error, event.scopeId);
202
213
  });
203
214
  }
@@ -230,6 +241,15 @@ export class BridgeSessionCore {
230
241
  this.attachedThreads.clear();
231
242
  this.threadTokenUsageAlerts.clear();
232
243
  if (!this.stopping) {
244
+ if (!this.codexReconnectPending) {
245
+ for (const scopeId of new Set([...this.activeTurns.values()].filter(active => !active.isObserved).map(active => active.scopeId))) {
246
+ const locale = this.localeForChat(scopeId);
247
+ void this.sendMessage(scopeId, locale === 'zh'
248
+ ? 'Codex 连接中断,正在重连。任务结果尚未确认,请先用 /status 检查;也可用 /cli 从本机继续。'
249
+ : 'Codex disconnected; reconnecting. The task result is unconfirmed. Check /status or use /cli locally.')
250
+ .catch(error => this.logger.warn('codex.disconnect_notice_failed', { scopeId, error: toErrorMeta(error) }));
251
+ }
252
+ }
233
253
  this.codexReconnectPending = true;
234
254
  this.pauseAppSnapshotWatchers();
235
255
  }
@@ -359,6 +379,9 @@ export class BridgeSessionCore {
359
379
  await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
360
380
  return;
361
381
  }
382
+ if (await this.queueObservedThreadMessage(event, locale, event.text.trim())) {
383
+ return;
384
+ }
362
385
  await this.startBoundTurnFromEvent(event, locale, event.text.trim());
363
386
  return;
364
387
  }
@@ -417,6 +440,9 @@ export class BridgeSessionCore {
417
440
  await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
418
441
  return;
419
442
  }
443
+ if (await this.queueObservedThreadMessage(event, locale, decision.text)) {
444
+ return;
445
+ }
420
446
  await this.startBoundTurnFromEvent(event, locale, decision.text);
421
447
  }
422
448
  async handleCommand(event, locale, name, args) {
@@ -576,6 +602,20 @@ export class BridgeSessionCore {
576
602
  await this.handleLoginCancelCommand(scopeId, locale, args);
577
603
  return;
578
604
  }
605
+ case 'cli': {
606
+ const binding = this.store.getBinding(scopeId);
607
+ const server = this.app.getServerStatus();
608
+ if (!server.running || !server.port) {
609
+ await this.sendMessage(scopeId, locale === 'zh' ? 'Codex 服务不可用,请在本机检查 foxclaw status。' : 'Codex server unavailable. Check foxclaw status locally.');
610
+ return;
611
+ }
612
+ const threadArg = binding ? ` ${JSON.stringify(binding.threadId)}` : '';
613
+ await this.sendMessage(scopeId, (locale === 'zh'
614
+ ? '在运行桥的这台机器上执行,进入同一个 Codex 服务后可中断或继续会话:\n'
615
+ : 'Run on the bridge host to interrupt or continue in the same Codex server:\n')
616
+ + `codex resume --remote ws://127.0.0.1:${server.port}${threadArg}`);
617
+ return;
618
+ }
579
619
  case 'logout': {
580
620
  await this.handleLogoutCommand(scopeId, locale, args);
581
621
  return;
@@ -1066,6 +1106,21 @@ export class BridgeSessionCore {
1066
1106
  async handleCallback(event) {
1067
1107
  const scopeId = event.scopeId;
1068
1108
  const locale = this.localeForChat(scopeId, event.languageCode);
1109
+ if (this.forceTakeoversInProgress.has(scopeId)) {
1110
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_busy'));
1111
+ return;
1112
+ }
1113
+ const forceTakeoverMatch = /^takeover:(confirm|cancel):([a-f0-9]+)$/.exec(event.data);
1114
+ if (forceTakeoverMatch) {
1115
+ await this.handleForceTakeoverCallback(event, locale, forceTakeoverMatch[1], forceTakeoverMatch[2]);
1116
+ return;
1117
+ }
1118
+ const loginCancelMatch = /^login:cancel:(.+)$/.exec(event.data);
1119
+ if (loginCancelMatch) {
1120
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'button_cancel'));
1121
+ await this.handleLoginCancelCommand(scopeId, locale, [loginCancelMatch[1]]);
1122
+ return;
1123
+ }
1069
1124
  const interruptMatch = /^turn:interrupt:(.+)$/.exec(event.data);
1070
1125
  if (interruptMatch) {
1071
1126
  await this.handleTurnInterruptCallback(event, interruptMatch[1], locale);
@@ -3648,7 +3703,9 @@ export class BridgeSessionCore {
3648
3703
  if (!scopeId)
3649
3704
  return;
3650
3705
  try {
3651
- await this.sendMessage(scopeId, t(this.localeForChat(scopeId), 'bridge_error', { error: formatUserError(error) }));
3706
+ const locale = this.localeForChat(scopeId);
3707
+ const hint = isThreadActiveWriterError(error) ? `\n${t(locale, 'force_takeover_hint')}` : '';
3708
+ await this.sendMessage(scopeId, t(locale, 'bridge_error', { error: formatUserError(error) }) + hint);
3652
3709
  }
3653
3710
  catch (notifyError) {
3654
3711
  this.logger.error('telegram.error_notification_failed', { error: toErrorMeta(notifyError), scopeId });
@@ -3881,7 +3938,7 @@ export class BridgeSessionCore {
3881
3938
  }
3882
3939
  }
3883
3940
  for (const active of [...this.activeTurns.values()]) {
3884
- if (recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
3941
+ if (active.isObserved || recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
3885
3942
  continue;
3886
3943
  }
3887
3944
  try {
@@ -3894,6 +3951,10 @@ export class BridgeSessionCore {
3894
3951
  turnId: active.turnId,
3895
3952
  error: toErrorMeta(error),
3896
3953
  });
3954
+ await this.sendMessage(active.scopeId, this.localeForChat(active.scopeId) === 'zh'
3955
+ ? '连接已恢复,但无法确认原任务状态。任务未自动重发,请用 /cli 检查原会话后再继续。'
3956
+ : 'Connected again, but the original task state could not be confirmed. It was not resent. Use /cli to inspect the session.')
3957
+ .catch(notifyError => this.logger.warn('codex.recovery_notice_failed', { error: toErrorMeta(notifyError) }));
3897
3958
  }
3898
3959
  }
3899
3960
  await this.recoverQueuedTurns();
@@ -4240,6 +4301,10 @@ export class BridgeSessionCore {
4240
4301
  }
4241
4302
  }
4242
4303
  async handleTakeoverCommand(event, locale, args) {
4304
+ if (args[0] === '--force') {
4305
+ await this.prepareForceTakeover(event, locale, args.slice(1).join(' ').trim());
4306
+ return;
4307
+ }
4243
4308
  const scopeId = event.scopeId;
4244
4309
  const nextPrompt = args.join(' ').trim();
4245
4310
  if (!nextPrompt) {
@@ -4257,10 +4322,113 @@ export class BridgeSessionCore {
4257
4322
  await this.requestInterrupt(active);
4258
4323
  }
4259
4324
  await this.sendMessage(scopeId, t(locale, 'interrupt_requested_waiting'));
4260
- await active.completion;
4325
+ let timer;
4326
+ try {
4327
+ await Promise.race([
4328
+ active.completion,
4329
+ new Promise((_, reject) => {
4330
+ timer = setTimeout(() => reject(new Error(locale === 'zh'
4331
+ ? '等待中断完成超时,尚未发送新任务。请用 /status 检查,或用 /cli 从终端恢复。'
4332
+ : 'Interrupt confirmation timed out. No replacement task was sent. Check /status or use /cli to recover.')), 30_000);
4333
+ }),
4334
+ ]);
4335
+ }
4336
+ finally {
4337
+ clearTimeout(timer);
4338
+ }
4261
4339
  }
4262
4340
  await this.startBoundTurnFromEvent(event, locale, nextPrompt);
4263
4341
  }
4342
+ async prepareForceTakeover(event, locale, prompt) {
4343
+ const scopeId = event.scopeId;
4344
+ this.pendingForceTakeovers.delete(scopeId);
4345
+ if (!prompt) {
4346
+ await this.sendMessage(scopeId, t(locale, 'usage_takeover'));
4347
+ return;
4348
+ }
4349
+ if (!scopeId.startsWith('telegram:') || event.userId !== this.config.tgAllowedUserId) {
4350
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_trusted_telegram_only'));
4351
+ return;
4352
+ }
4353
+ const binding = this.store.getBinding(scopeId);
4354
+ if (!binding) {
4355
+ await this.sendMessage(scopeId, t(locale, 'watch_no_thread_bound'));
4356
+ return;
4357
+ }
4358
+ const home = this.config.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
4359
+ try {
4360
+ const writer = await this.externalWriterControl.inspect(home, binding.threadId);
4361
+ const id = crypto.randomBytes(8).toString('hex');
4362
+ this.pendingForceTakeovers.set(scopeId, {
4363
+ id, expiresAt: Date.now() + 60_000, event, threadId: binding.threadId, home, prompt, writer,
4364
+ });
4365
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_confirm', {
4366
+ threadId: binding.threadId, pid: String(writer.pid), cwd: writer.cwd, prompt,
4367
+ }), [[
4368
+ { text: t(locale, 'button_force_takeover'), callback_data: `takeover:confirm:${id}` },
4369
+ { text: t(locale, 'button_cancel'), callback_data: `takeover:cancel:${id}` },
4370
+ ]]);
4371
+ }
4372
+ catch (error) {
4373
+ this.pendingForceTakeovers.delete(scopeId);
4374
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
4375
+ }
4376
+ }
4377
+ async handleForceTakeoverCallback(event, locale, action, id) {
4378
+ const scopeId = event.scopeId;
4379
+ const pending = this.pendingForceTakeovers.get(scopeId);
4380
+ if (!pending || pending.id !== id || pending.event.userId !== event.userId
4381
+ || event.userId !== this.config.tgAllowedUserId || pending.expiresAt <= Date.now()) {
4382
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_expired'));
4383
+ return;
4384
+ }
4385
+ // Claim before any await: duplicate callbacks cannot stop or submit twice.
4386
+ this.pendingForceTakeovers.delete(scopeId);
4387
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, action === 'cancel' ? 'button_cancel' : 'button_force_takeover'));
4388
+ if (action === 'cancel') {
4389
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_cancelled'));
4390
+ return;
4391
+ }
4392
+ await this.withLock(scopeId, async () => {
4393
+ if (pending.expiresAt <= Date.now() || this.store.getBinding(scopeId)?.threadId !== pending.threadId) {
4394
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_expired'));
4395
+ return;
4396
+ }
4397
+ if (this.externalAuthValidationInProgress) {
4398
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
4399
+ return;
4400
+ }
4401
+ this.forceTakeoversInProgress.add(scopeId);
4402
+ this.turnStartInProgress += 1;
4403
+ try {
4404
+ try {
4405
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_stopping', { pid: String(pending.writer.pid) }));
4406
+ await this.externalWriterControl.stop(pending.home, pending.threadId, pending.writer);
4407
+ this.logger.warn('codex.external_writer_stopped', { scopeId, threadId: pending.threadId, pid: pending.writer.pid });
4408
+ await this.stopWatchingScopeThread(scopeId);
4409
+ this.attachedThreads.delete(attachedThreadKey(scopeId, pending.threadId));
4410
+ // Never replace a missing thread with an unrelated new conversation.
4411
+ const binding = this.store.getBinding(scopeId);
4412
+ if (binding?.threadId !== pending.threadId)
4413
+ throw new Error(t(locale, 'force_takeover_expired'));
4414
+ await this.ensureThreadReady(scopeId, binding, { recoverMissingThread: false });
4415
+ }
4416
+ catch (error) {
4417
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
4418
+ return;
4419
+ }
4420
+ this.store.cancelQueuedTurnInputs(scopeId);
4421
+ await this.sendMessage(scopeId, t(locale, 'force_takeover_acquired', { threadId: pending.threadId }));
4422
+ // Normal turn-start error handling preserves ambiguous submission failures;
4423
+ // do not automatically retry a possibly accepted prompt.
4424
+ await this.startBoundTurnFromEvent(pending.event, locale, pending.prompt);
4425
+ }
4426
+ finally {
4427
+ this.forceTakeoversInProgress.delete(scopeId);
4428
+ this.turnStartInProgress -= 1;
4429
+ }
4430
+ });
4431
+ }
4264
4432
  async handleQueueCommand(event, locale, args) {
4265
4433
  const scopeId = event.scopeId;
4266
4434
  const nextPrompt = args.join(' ').trim();
@@ -4269,12 +4437,15 @@ export class BridgeSessionCore {
4269
4437
  return;
4270
4438
  }
4271
4439
  const active = this.findActiveTurn(scopeId);
4272
- if (!active) {
4273
- await this.startBoundTurnFromEvent(event, locale, nextPrompt);
4440
+ const observedThreadId = active?.isObserved
4441
+ ? active.threadId
4442
+ : this.observedThreadWatchers.get(scopeId)?.threadId ?? null;
4443
+ if (observedThreadId) {
4444
+ await this.queueObservedThreadMessage(event, locale, nextPrompt, observedThreadId);
4274
4445
  return;
4275
4446
  }
4276
- if (active.isObserved) {
4277
- await this.sendMessage(scopeId, t(locale, 'watch_read_only_active'));
4447
+ if (!active) {
4448
+ await this.startBoundTurnFromEvent(event, locale, nextPrompt);
4278
4449
  return;
4279
4450
  }
4280
4451
  await this.queuePromptAfterActiveTurn(event, locale, nextPrompt);
@@ -4285,7 +4456,7 @@ export class BridgeSessionCore {
4285
4456
  return;
4286
4457
  }
4287
4458
  if (active.isObserved) {
4288
- await this.sendMessage(event.scopeId, t(locale, 'watch_read_only_active'));
4459
+ await this.queueObservedThreadMessage(event, locale, text, active.threadId);
4289
4460
  return;
4290
4461
  }
4291
4462
  const settings = this.store.getChatSettings(event.scopeId);
@@ -4311,6 +4482,34 @@ export class BridgeSessionCore {
4311
4482
  await this.startBoundTurnFromEvent(event, locale, text);
4312
4483
  }
4313
4484
  }
4485
+ async queueObservedThreadMessage(event, locale, text, threadId = this.observedThreadWatchers.get(event.scopeId)?.threadId ?? null) {
4486
+ if (!threadId) {
4487
+ return false;
4488
+ }
4489
+ const input = await this.buildTurnInput({
4490
+ threadId,
4491
+ cwd: this.store.getBinding(event.scopeId)?.cwd ?? this.config.defaultCwd,
4492
+ }, { ...event, text }, locale);
4493
+ const clientUserMessageId = `foxclaw:${event.scopeId}:${event.messageId}`;
4494
+ try {
4495
+ const queued = await this.app.queueThreadInput(threadId, clientUserMessageId, input);
4496
+ await this.sendMessage(event.scopeId, t(locale, 'watch_prompt_queued', {
4497
+ id: shortId(queued.queuedSubmissionId),
4498
+ }));
4499
+ }
4500
+ catch (error) {
4501
+ if (!isThreadQueueUnsupportedError(error)) {
4502
+ throw error;
4503
+ }
4504
+ this.logger.warn('codex.observed_thread_queue_unsupported', {
4505
+ scopeId: event.scopeId,
4506
+ threadId,
4507
+ error: toErrorMeta(error),
4508
+ });
4509
+ await this.sendMessage(event.scopeId, t(locale, 'watch_queue_unsupported'));
4510
+ }
4511
+ return true;
4512
+ }
4314
4513
  async queuePromptAfterActiveTurn(event, locale, text) {
4315
4514
  const active = this.findActiveTurn(event.scopeId);
4316
4515
  if (!active) {
@@ -5339,7 +5538,7 @@ export class BridgeSessionCore {
5339
5538
  t(locale, 'login_code', { value: login.userCode }),
5340
5539
  t(locale, 'login_id', { value: login.loginId }),
5341
5540
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5342
- ].join('\n'));
5541
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5343
5542
  }
5344
5543
  catch (error) {
5345
5544
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5382,7 +5581,7 @@ export class BridgeSessionCore {
5382
5581
  t(locale, 'login_code', { value: login.userCode }),
5383
5582
  t(locale, 'login_id', { value: login.loginId }),
5384
5583
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5385
- ].join('\n'));
5584
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5386
5585
  }
5387
5586
  catch (error) {
5388
5587
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5547,6 +5746,11 @@ export class BridgeSessionCore {
5547
5746
  }
5548
5747
  }
5549
5748
  async handleLoginDeviceCommand(scopeId, locale) {
5749
+ const pendingLoginId = this.pendingLoginsByScope.get(scopeId);
5750
+ if (pendingLoginId) {
5751
+ await this.sendMessage(scopeId, t(locale, 'login_cancel_hint', { value: pendingLoginId }), this.loginCancelKeyboard(pendingLoginId, locale));
5752
+ return;
5753
+ }
5550
5754
  const login = await this.app.startDeviceLogin();
5551
5755
  const oldLoginId = this.pendingLoginsByScope.get(scopeId);
5552
5756
  if (oldLoginId) {
@@ -5561,24 +5765,41 @@ export class BridgeSessionCore {
5561
5765
  t(locale, 'login_code', { value: login.userCode }),
5562
5766
  t(locale, 'login_id', { value: login.loginId }),
5563
5767
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5564
- ].join('\n'));
5768
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5769
+ }
5770
+ loginCancelKeyboard(loginId, locale) {
5771
+ return [[{ text: t(locale, 'button_cancel'), callback_data: `login:cancel:${loginId}` }]];
5565
5772
  }
5566
5773
  async handleLoginCancelCommand(scopeId, locale, args) {
5567
5774
  const loginId = args[0]?.trim() || this.pendingLoginsByScope.get(scopeId) || null;
5568
- if (!loginId) {
5775
+ if (!loginId || this.pendingLoginsByScope.get(scopeId) !== loginId) {
5569
5776
  await this.sendMessage(scopeId, t(locale, 'login_cancel_no_pending'));
5570
5777
  return;
5571
5778
  }
5572
5779
  const pendingAuthAdd = this.pendingAuthAddsByLoginId.get(loginId) ?? null;
5573
- await this.app.cancelLogin(loginId);
5780
+ // Claim cancellation before awaiting RPC: completion notifications may arrive first.
5574
5781
  this.pendingLoginsByScope.delete(scopeId);
5575
5782
  this.pendingLoginScopesById.delete(loginId);
5576
5783
  this.pendingAuthAddsByLoginId.delete(loginId);
5784
+ let cancelError;
5785
+ try {
5786
+ await this.app.cancelLogin(loginId);
5787
+ }
5788
+ catch (error) {
5789
+ cancelError = error;
5790
+ this.logger.warn('codex.login_cancel_failed', { error: toErrorMeta(error) });
5791
+ }
5577
5792
  if (pendingAuthAdd) {
5578
5793
  await this.restorePendingAuthAdd(pendingAuthAdd);
5579
5794
  await this.sendMessage(scopeId, t(locale, 'auth_add_cancelled'));
5580
5795
  return;
5581
5796
  }
5797
+ if (cancelError) {
5798
+ await this.sendMessage(scopeId, locale === 'zh'
5799
+ ? '已退出本地登录流程,但未能确认服务端取消。请勿继续使用旧验证码;可重新登录或用 /codex_restart 重置登录服务。'
5800
+ : 'Local login flow cleared, but server cancellation is unconfirmed. Do not use the old code; start a new login or use /codex_restart.');
5801
+ return;
5802
+ }
5582
5803
  await this.sendMessage(scopeId, t(locale, 'login_cancelled'));
5583
5804
  }
5584
5805
  async handleLogoutCommand(scopeId, locale, args) {
@@ -5601,6 +5822,10 @@ export class BridgeSessionCore {
5601
5822
  await this.sendMessage(scopeId, t(locale, 'no_active_turn'));
5602
5823
  return;
5603
5824
  }
5825
+ if (active.isObserved) {
5826
+ await this.sendMessage(scopeId, t(locale, 'watch_read_only_active'));
5827
+ return;
5828
+ }
5604
5829
  await this.app.steerTurn(active.threadId, active.turnId, [{
5605
5830
  type: 'text',
5606
5831
  text,
@@ -6121,6 +6346,20 @@ export class BridgeSessionCore {
6121
6346
  await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
6122
6347
  }
6123
6348
  }
6349
+ async refreshRepairedAuthChoice(event, record, candidateName, locale) {
6350
+ const state = await this.listCodexAuthState();
6351
+ if (state.candidates.find(candidate => candidate.name === candidateName)?.state === 'needs_repair')
6352
+ return false;
6353
+ record.candidates = state.candidates;
6354
+ record.createdAt = Date.now();
6355
+ clampCodexAuthListOffset(record);
6356
+ await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh'
6357
+ ? '授权状态已变化,已刷新面板。' : 'Auth state changed; panel refreshed.');
6358
+ if (record.messageId !== null) {
6359
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
6360
+ }
6361
+ return true;
6362
+ }
6124
6363
  async handleAuthRepairMenuCallback(event, localId, index, locale) {
6125
6364
  const record = this.pendingAuthChoiceLists.get(localId);
6126
6365
  if (!record) {
@@ -6136,6 +6375,8 @@ export class BridgeSessionCore {
6136
6375
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6137
6376
  return;
6138
6377
  }
6378
+ if (await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6379
+ return;
6139
6380
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
6140
6381
  if (record.messageId !== null) {
6141
6382
  await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
@@ -6156,6 +6397,8 @@ export class BridgeSessionCore {
6156
6397
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6157
6398
  return;
6158
6399
  }
6400
+ if (action !== 'cancel' && await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6401
+ return;
6159
6402
  if (action === 'cancel') {
6160
6403
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
6161
6404
  const state = await this.listCodexAuthState();
@@ -11811,6 +12054,12 @@ function isThreadActiveWriterError(error) {
11811
12054
  function isNoActiveTurnToSteerError(error) {
11812
12055
  return error instanceof Error && /no active turn to steer/i.test(error.message);
11813
12056
  }
12057
+ function isThreadQueueUnsupportedError(error) {
12058
+ const message = formatUserError(error).toLowerCase();
12059
+ return message.includes('method not found')
12060
+ || message.includes('unknown method')
12061
+ || message.includes('thread/queue/add') && message.includes('not supported');
12062
+ }
11814
12063
  function isThreadNewCwdCreateConfirmation(text) {
11815
12064
  return /^(y|yes|ok|okay|confirm|create|mkdir|确定|确认|创建|新建|好)$/i.test(text.trim());
11816
12065
  }
package/dist/i18n.d.ts CHANGED
@@ -52,6 +52,8 @@ declare const MESSAGES: {
52
52
  readonly cmd_desc_account: "Codex account";
53
53
  readonly cmd_desc_quota: "Codex quota";
54
54
  readonly cmd_desc_login_device: "ChatGPT device login";
55
+ readonly cmd_desc_login_cancel: "Cancel the pending login";
56
+ readonly cmd_desc_cli: "Continue this session in the local CLI";
55
57
  readonly cmd_desc_threads: "Recent threads";
56
58
  readonly cmd_desc_open: "Open a cached thread";
57
59
  readonly cmd_desc_goal: "Thread goal";
@@ -246,13 +248,13 @@ declare const MESSAGES: {
246
248
  readonly auth_sync_test_sent: "Auth sync test complete: sent {sent}, replies {replied}.";
247
249
  readonly auth_sync_test_missing: "Missing replies: {value}";
248
250
  readonly auth_sync_push_blocked_active: "Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.";
249
- readonly auth_sync_safe_starting: "Auditing every node, selecting the newest valid auth, and safely synchronizing the cluster...";
251
+ readonly auth_sync_safe_starting: "Auditing every node and distributing valid auth. An unreachable peer can add up to 5 minutes per audit phase. Use /auth sync status to check progress.";
250
252
  readonly auth_sync_safe_done: "Safe auth sync complete: local synced {localSynced}, local skipped {localSkipped}; cross-node sent {sent}, skipped {skipped}.";
251
253
  readonly auth_cluster_audit_starting: "Asking all auth-sync peers to validate accounts, reconcile the newest usable credentials, and refresh credentials at least 8 days old...";
252
254
  readonly auth_cluster_audit_blocked_active: "Cluster auth check requires every local runtime, approval, input, login, and auth mirror write to be idle.";
253
255
  readonly auth_cluster_audit_failed: "Cluster auth check failed: {error}";
254
256
  readonly auth_cluster_audit_summary: "Cluster auth check: nodes {responded}/{expected}; accounts checked {checked}, usable {valid}, unusable {invalid}.";
255
- readonly auth_cluster_audit_synced: "Newest usable auth distributed: {value}";
257
+ readonly auth_cluster_audit_synced: "Valid auth selected and sent (remote import not confirmed): {value}";
256
258
  readonly auth_cluster_audit_repair: "Marked for manual repair after multi-node consensus: {value}";
257
259
  readonly auth_cluster_audit_missing: "No response: {value}";
258
260
  readonly auth_cluster_audit_busy: "Busy nodes: {value}";
@@ -301,7 +303,17 @@ declare const MESSAGES: {
301
303
  readonly working: "Working...";
302
304
  readonly usage_open: "Usage: /open <n>";
303
305
  readonly usage_watch: "Usage: /watch [n]";
304
- readonly usage_takeover: "Usage: /takeover <message>";
306
+ readonly usage_takeover: "Usage: /takeover <message> or /takeover --force <message> (confirm stopping a local CLI)";
307
+ readonly force_takeover_trusted_telegram_only: "Force takeover is only available to the configured trusted Telegram user.";
308
+ readonly force_takeover_confirm: "Force takeover of thread {threadId}?\nLocal Codex CLI PID: {pid}\nWorking directory: {cwd}\nNext prompt: {prompt}\nThis will terminate that CLI (SIGTERM, then SIGKILL after 5 seconds if needed). Unfinished work may be interrupted; child commands may continue running. No edits are rolled back. Confirmation expires in 60 seconds.";
309
+ readonly button_force_takeover: "Confirm force takeover";
310
+ readonly force_takeover_busy: "Force takeover is in progress. Please wait before switching threads.";
311
+ readonly force_takeover_hint: "Use /watch then /queue <message> to queue for that CLI. To stop a local CLI and hand the thread to this bridge, use /takeover --force <message> and confirm.";
312
+ readonly force_takeover_expired: "Confirmation expired or thread changed. Send /takeover --force <message> again.";
313
+ readonly force_takeover_cancelled: "Force takeover cancelled. Nothing was stopped or submitted.";
314
+ readonly force_takeover_stopping: "Stopping the confirmed CLI PID {pid} and checking its writer lock...";
315
+ readonly force_takeover_failed: "Force takeover not completed; no new prompt was sent. If stopping already began, the CLI may have exited. Check /status before retrying. Reason: {error}";
316
+ readonly force_takeover_acquired: "Bridge acquired thread {threadId}. Preparing the requested prompt; this is not task completion.";
305
317
  readonly usage_queue: "Usage: /queue <message>";
306
318
  readonly unknown_cached_thread: "Unknown cached thread. Run /threads first.";
307
319
  readonly cached_thread_unavailable: "That cached thread is no longer available. Run /threads again.";
@@ -309,7 +321,9 @@ declare const MESSAGES: {
309
321
  readonly watch_already_enabled: "Already watching thread {threadId}.";
310
322
  readonly watch_started_active: "Watching thread {threadId}. Live Codex updates from the current turn will appear here.";
311
323
  readonly watch_started_idle: "Watching thread {threadId}. I will mirror the next live turn from Codex CLI here.";
312
- readonly watch_read_only_active: "This chat is watching that turn read-only. Use approval commands if needed, or /unwatch before sending a new prompt.";
324
+ readonly watch_read_only_active: "This chat is watching that turn read-only, so this operation cannot take ownership. Send plain text or use /queue to queue the next prompt, or /unwatch to start separately.";
325
+ readonly watch_prompt_queued: "Queued for the watched Codex CLI session ({id}). The CLI will continue with it after the current turn.";
326
+ readonly watch_queue_unsupported: "This Codex version does not support cross-client session queues. Update Codex to 0.151.0 or later.";
313
327
  readonly watch_not_enabled: "This chat is not watching any thread.";
314
328
  readonly watch_stopped: "Stopped watching thread {threadId}.";
315
329
  readonly queued_prompt_set: "Queued #{position} ({id}). I will send it after the current turn finishes.";
@@ -778,6 +792,8 @@ declare const MESSAGES: {
778
792
  readonly cmd_desc_account: "Codex 账号";
779
793
  readonly cmd_desc_quota: "Codex 用量";
780
794
  readonly cmd_desc_login_device: "ChatGPT 设备登录";
795
+ readonly cmd_desc_login_cancel: "取消当前登录流程";
796
+ readonly cmd_desc_cli: "从本机 CLI 继续当前会话";
781
797
  readonly cmd_desc_threads: "最近线程";
782
798
  readonly cmd_desc_open: "打开缓存线程";
783
799
  readonly cmd_desc_goal: "线程目标";
@@ -972,13 +988,13 @@ declare const MESSAGES: {
972
988
  readonly auth_sync_test_sent: "auth sync 测试完成:已发送 {sent},收到回应 {replied}。";
973
989
  readonly auth_sync_test_missing: "未回应:{value}";
974
990
  readonly auth_sync_push_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。";
975
- readonly auth_sync_safe_starting: "正在自检全部节点、选择最新有效 auth,并安全同步整个集群...";
991
+ readonly auth_sync_safe_starting: "正在自检各节点并分发有效授权。节点未回应时,每轮自检最多等待 5 分钟;可用 /auth sync status 查看进展。";
976
992
  readonly auth_sync_safe_done: "安全 auth 同步完成:本机同步 {localSynced},本机跳过 {localSkipped};跨节点发送 {sent},跳过 {skipped}。";
977
993
  readonly auth_cluster_audit_starting: "正在通知所有 auth sync 节点逐账号自检,协商最新有效凭据,并由本节点刷新已满 8 天的 auth...";
978
994
  readonly auth_cluster_audit_blocked_active: "集群 auth 自检要求本机所有 runtime、审批、待输入、登录和 auth 镜像写入均为空闲。";
979
995
  readonly auth_cluster_audit_failed: "集群 auth 自检失败:{error}";
980
996
  readonly auth_cluster_audit_summary: "集群 auth 自检:节点 {responded}/{expected};检查账号 {checked},有效 {valid},无有效副本 {invalid}。";
981
- readonly auth_cluster_audit_synced: "已采用并分发最新有效 auth:{value}";
997
+ readonly auth_cluster_audit_synced: "已选出并发送有效授权(远端导入尚未确认):{value}";
982
998
  readonly auth_cluster_audit_repair: "多节点确认无效,已标记问号等待人工处理:{value}";
983
999
  readonly auth_cluster_audit_missing: "未回应节点:{value}";
984
1000
  readonly auth_cluster_audit_busy: "忙碌节点:{value}";
@@ -1027,7 +1043,17 @@ declare const MESSAGES: {
1027
1043
  readonly working: "处理中...";
1028
1044
  readonly usage_open: "用法:/open <编号>";
1029
1045
  readonly usage_watch: "用法:/watch [编号]";
1030
- readonly usage_takeover: "用法:/takeover <消息>";
1046
+ readonly usage_takeover: "用法:/takeover <消息>;或 /takeover --force <消息>(确认后停止本机 CLI)";
1047
+ readonly force_takeover_trusted_telegram_only: "强制接管仅对配置中的可信 Telegram 用户开放。";
1048
+ readonly force_takeover_confirm: "强制接管 thread {threadId}?\n本机 Codex CLI PID:{pid}\n工作目录:{cwd}\n接管后消息:{prompt}\n将终止这个 CLI(先 SIGTERM,5 秒后仍未退出则 SIGKILL)。未完成任务可能被中断,已启动的子命令可能继续运行;不会回滚文件修改。确认 60 秒内有效。";
1049
+ readonly button_force_takeover: "确认强制接管";
1050
+ readonly force_takeover_busy: "正在强制接管,请等待结果后再切换会话。";
1051
+ readonly force_takeover_hint: "可先 /watch 再 /queue <消息> 排队给原 CLI;如需停止本机 CLI、让桥接管,请发送 /takeover --force <消息> 并确认。";
1052
+ readonly force_takeover_expired: "确认已过期或 thread 已切换,请重新发送 /takeover --force <消息>。";
1053
+ readonly force_takeover_cancelled: "已取消强制接管,没有停止进程或发送新任务。";
1054
+ readonly force_takeover_stopping: "正在停止已确认的 CLI PID {pid},并检查写入锁是否释放…";
1055
+ readonly force_takeover_failed: "强制接管未完成,没有发送新任务。如果已开始停止,CLI 可能已经退出。请先 /status 检查再重试。原因:{error}";
1056
+ readonly force_takeover_acquired: "桥已取得 thread {threadId} 的写入权,准备发送指定消息;这不代表任务已完成。";
1031
1057
  readonly usage_queue: "用法:/queue <消息>";
1032
1058
  readonly unknown_cached_thread: "找不到这个缓存线程,请先运行 /threads。";
1033
1059
  readonly cached_thread_unavailable: "这个缓存线程已经不可用,请重新运行 /threads。";
@@ -1035,7 +1061,9 @@ declare const MESSAGES: {
1035
1061
  readonly watch_already_enabled: "已经在观察线程 {threadId}。";
1036
1062
  readonly watch_started_active: "已开始观察线程 {threadId}。当前这轮 Codex 更新会同步到这里。";
1037
1063
  readonly watch_started_idle: "已开始观察线程 {threadId}。之后 CLI 里这条线程一旦有新 live turn,我会同步到这里。";
1038
- readonly watch_read_only_active: "当前聊天正在只读观察这轮。需要审批时可用审批命令;要发送新 prompt 请先 /unwatch。";
1064
+ readonly watch_read_only_active: "当前聊天正在只读观察这轮,不能接管这项操作。直接发送文字或使用 /queue 可排入下一轮;要另行启动请先 /unwatch。";
1065
+ readonly watch_prompt_queued: "已排入正在观察的 Codex CLI 会话({id})。当前轮结束后,CLI 会自动接着执行。";
1066
+ readonly watch_queue_unsupported: "当前 Codex 不支持跨客户端会话队列,请升级到 Codex 0.151.0 或更高版本。";
1039
1067
  readonly watch_not_enabled: "当前没有正在观察的线程。";
1040
1068
  readonly watch_stopped: "已停止观察线程 {threadId}。";
1041
1069
  readonly queued_prompt_set: "已排队 #{position}({id})。当前回复结束后我会自动发送。";