@foxden-app/foxclaw 0.7.2 → 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.
package/.env.example CHANGED
@@ -1,5 +1,6 @@
1
1
  # Required for new installs: one or more bot tokens for this host.
2
2
  # Comma-separated bot tokens. By default, each bot gets an independent Codex runtime/auth selection.
3
+ # Homes use ~/.foxclaw/codex/telegram/@TelegramUsername/home (old numeric paths remain as links).
3
4
  TG_BOT_TOKENS=<telegram_bot_token>
4
5
  # Backward-compatible single-runtime setup.
5
6
  # In TG_BOT_TOKENS mode, if this exact token is also present in TG_BOT_TOKENS,
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.7.3 - 2026-09-05
6
+
7
+ ### 中文
8
+ - 修复代理或网络不稳时 Telegram、Codex RPC 和 WebSocket 可能长期无响应的问题:请求有明确截止时间,活动会话断连、恢复失败和 writer 冲突都会给出可继续操作的错误,不重放结果未知的请求。
9
+ - 登录、auth add 和 auth repair 流程增加取消按钮;取消失败也释放本地流程,旧按钮和其他聊天不能误取消当前登录。
10
+ - 新增 `/cli` 与 `foxclaw resume`,可让终端连接桥的同一 Codex app-server;`/watch` 继续只读,外部 CLI 的消息可用原生线程队列安全排队。
11
+ - 新增 `/takeover --force <消息>`:可信 Telegram 用户二次确认后,可精确停止占用目标 thread 的本机交互式 CLI,验证锁释放并恢复原 thread 后再提交消息。进程、锁或 thread 身份变化时明确拒绝,不删除锁、不改写 session 文件。
12
+ - 多 bot Codex 数据目录默认使用真实 `@Telegram用户名`。迁移既有数字目录并保留兼容链接,共享终端的 bot 使用同名目录入口指向原 home。
13
+ - 并行读取用户名,断网重启沿用已有名称;媒体发送通过稳定身份记录保持正确路由,目录冲突明确报错。
14
+ - auth 问号、修复和删除操作会重新读取最新状态;安全同步文案区分“已发送”和“远端已导入”,状态更新未生效时不再报告虚假成功。
15
+
16
+ ### English
17
+ - Bound Telegram, Codex RPC, and WebSocket waits so unstable proxy or network links fail explicitly instead of hanging. Active-session disconnects, recovery failures, and writer conflicts now include actionable recovery guidance without replaying ambiguous requests.
18
+ - Added cancel buttons to login, auth-add, and auth-repair flows. Local state is released even when cancellation fails, while stale or foreign-chat buttons cannot cancel the current login.
19
+ - Added `/cli` and `foxclaw resume` to connect a terminal to the bridge's Codex app-server. `/watch` remains read-only and external CLI prompts use Codex's native thread queue.
20
+ - Added `/takeover --force <message>` for a trusted Telegram user to confirm a precise local interactive-CLI handoff. FoxClaw revalidates process, lock, and thread identity, verifies lock release, resumes the original thread, and only then submits the prompt; it never deletes locks or edits session files.
21
+ - Name multi-bot Codex homes after verified Telegram usernames, migrating numeric directories with compatibility links and preserving shared terminal homes.
22
+ - Resolve usernames in parallel, reuse stored paths offline, preserve media routing through stable identity metadata, and reject directory conflicts.
23
+ - Auth repair/question-mark/delete actions now refresh current state first. Safe-sync wording distinguishes dispatch from confirmed remote import, and unapplied state changes no longer report false success.
24
+
5
25
  ## 0.7.2 - 2026-08-30
6
26
 
7
27
  ### 中文
package/README.md CHANGED
@@ -30,7 +30,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex 或
30
30
 
31
31
  ![FoxClaw Telegram 功能预览](./docs/assets/telegram-preview-zh.svg)
32
32
 
33
- 手机端不是简单转发消息,而是给 Codex 的常用工作流做了 Telegram 面板:`/setup` 调模型、推理强度、Fast tier、权限和 Agent/Plan 模式;`/auth` 管理多个 Codex 登录候选,触发限制时自动轮转;`/threads`、`/watch` 和审批按钮用于切线程、观察进度和处理权限请求。
33
+ 手机端不是简单转发消息,而是给 Codex 的常用工作流做了 Telegram 面板:`/setup` 调模型、推理强度、Fast tier、权限和 Agent/Plan 模式;`/auth` 管理多个 Codex 登录候选,触发限制时自动轮转;`/threads`、`/watch` 和审批按钮用于切线程、观察进度和处理权限请求。终端 CLI 卡住且持有 thread 时,可信用户还可用 `/takeover --force <消息>` 核对 PID、目录并二次确认,将同一 thread 安全交给桥继续执行。
34
34
 
35
35
  ## 从这里开始
36
36
 
package/README_EN.md CHANGED
@@ -30,7 +30,7 @@ No public server required. FoxClaw runs on your own computer, talks to `codex ap
30
30
 
31
31
  ![FoxClaw Telegram feature preview](./docs/assets/telegram-preview-en.svg)
32
32
 
33
- FoxClaw is more than message forwarding. It provides Telegram panels for Codex workflows: `/setup` adjusts model, reasoning, Fast tier, access, and Agent/Plan mode; `/auth` manages multiple Codex auth candidates and rotates them on usage limits; `/threads`, `/watch`, and approval buttons handle thread switching, progress monitoring, and permission requests.
33
+ FoxClaw is more than message forwarding. It provides Telegram panels for Codex workflows: `/setup` adjusts model, reasoning, Fast tier, access, and Agent/Plan mode; `/auth` manages multiple Codex auth candidates and rotates them on usage limits; `/threads`, `/watch`, and approval buttons handle thread switching, progress monitoring, and permission requests. If a local interactive Codex CLI is stuck while holding a thread, the trusted Telegram user can use `/takeover --force <message>`, verify its PID and working directory, and explicitly confirm a safe handoff to the bridge.
34
34
 
35
35
  ## Start Here
36
36
 
@@ -776,11 +776,11 @@ export class CrossNodeAuthSync {
776
776
  this.recordEvent({
777
777
  direction: 'local',
778
778
  kind: 'audit.state',
779
- stage: message.state,
779
+ stage: applied ? message.state : 'skipped',
780
780
  peer: null,
781
781
  requestId: message.requestId,
782
782
  candidateName: message.candidateName,
783
- detail: null,
783
+ detail: applied ? null : 'local candidate did not match the audited identity or refresh timestamp',
784
784
  });
785
785
  }
786
786
  async buildAuditReport() {
@@ -78,6 +78,7 @@ export declare class CodexAppClient extends EventEmitter {
78
78
  private port;
79
79
  private connected;
80
80
  private userAgent;
81
+ private readonly requestTimeoutMs;
81
82
  constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
82
83
  isConnected(): boolean;
83
84
  getUserAgent(): string | null;
@@ -33,6 +33,7 @@ export class CodexAppClient extends EventEmitter {
33
33
  port = null;
34
34
  connected = false;
35
35
  userAgent = null;
36
+ requestTimeoutMs = 30_000;
36
37
  constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
37
38
  super();
38
39
  this.codexCliBin = codexCliBin;
@@ -577,6 +578,9 @@ export class CodexAppClient extends EventEmitter {
577
578
  port: state.port,
578
579
  error: error instanceof Error ? error.message : String(error),
579
580
  });
581
+ if (isProcessAlive(state.pid)) {
582
+ throw new Error(`Managed Codex app-server pid ${state.pid} is alive but unreachable; retry the connection or explicitly restart it.`, { cause: error });
583
+ }
580
584
  this.clearServerStateForPid(state.pid);
581
585
  return false;
582
586
  }
@@ -588,11 +592,18 @@ export class CodexAppClient extends EventEmitter {
588
592
  try {
589
593
  await new Promise((resolve, reject) => {
590
594
  const ws = new WebSocket(url);
595
+ const timer = setTimeout(() => {
596
+ ws.close();
597
+ reject(new Error('WebSocket handshake timed out'));
598
+ }, 2000);
591
599
  const onError = (event) => {
600
+ clearTimeout(timer);
592
601
  ws.close();
593
602
  reject(new Error(`WebSocket connect failed: ${String(event.type)}`));
594
603
  };
595
604
  ws.addEventListener('open', () => {
605
+ clearTimeout(timer);
606
+ ws.removeEventListener('error', onError);
596
607
  this.socket = ws;
597
608
  this.connected = true;
598
609
  ws.addEventListener('message', message => this.handleMessage(String(message.data)));
@@ -608,7 +619,12 @@ export class CodexAppClient extends EventEmitter {
608
619
  });
609
620
  });
610
621
  ws.addEventListener('error', err => {
611
- this.logger.warn('codex.ws.error', String(err.message ?? 'unknown'));
622
+ this.logger.warn('codex.ws.error', { message: err.message || 'WebSocket transport error', port: this.port });
623
+ if (this.socket === ws) {
624
+ this.socket = null;
625
+ ws.close();
626
+ this.handleDisconnect({ source: 'websocket-error', port: this.port });
627
+ }
612
628
  });
613
629
  resolve();
614
630
  }, { once: true });
@@ -649,8 +665,22 @@ export class CodexAppClient extends EventEmitter {
649
665
  }
650
666
  const id = String(++this.requestId);
651
667
  return new Promise((resolve, reject) => {
652
- this.pending.set(id, { resolve, reject });
653
- this.send({ jsonrpc: '2.0', id, method, params });
668
+ const timer = setTimeout(() => {
669
+ this.pending.delete(id);
670
+ this.logger.warn('codex.request_timeout', { method, timeoutMs: this.requestTimeoutMs });
671
+ reject(new Error(`Codex request timed out: ${method}. The result is unknown; check /status or use foxclaw resume before retrying.`));
672
+ }, this.requestTimeoutMs);
673
+ this.pending.set(id, {
674
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
675
+ reject: (error) => { clearTimeout(timer); reject(error); },
676
+ });
677
+ try {
678
+ this.send({ jsonrpc: '2.0', id, method, params });
679
+ }
680
+ catch (error) {
681
+ this.pending.get(id)?.reject(error);
682
+ this.pending.delete(id);
683
+ }
654
684
  });
655
685
  }
656
686
  send(payload) {
@@ -690,6 +720,7 @@ export class CodexAppClient extends EventEmitter {
690
720
  }
691
721
  }
692
722
  handleDisconnect(meta) {
723
+ this.logger.warn('codex.disconnected', meta);
693
724
  if (this.connected) {
694
725
  this.connected = false;
695
726
  }
@@ -711,7 +742,7 @@ export class CodexAppClient extends EventEmitter {
711
742
  this.reconnectTimer = setTimeout(async () => {
712
743
  this.reconnectTimer = null;
713
744
  try {
714
- await this.startServer();
745
+ await this.start();
715
746
  }
716
747
  catch (error) {
717
748
  this.logger.error('codex.reconnect_failed', { error: String(error) });
@@ -0,0 +1,12 @@
1
+ export interface ExternalWriterIdentity {
2
+ pid: number;
3
+ startTime: string;
4
+ exe: string;
5
+ lockDevice: string;
6
+ lockInode: string;
7
+ cwd: string;
8
+ }
9
+ export declare const externalWriterControl: {
10
+ inspect(home: string, threadId: string): Promise<ExternalWriterIdentity>;
11
+ stop(home: string, threadId: string, expected: ExternalWriterIdentity): Promise<void>;
12
+ };
@@ -0,0 +1,27 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ const helper = fileURLToPath(new URL('../../scripts/force-takeover.py', import.meta.url));
6
+ async function runHelper(home, threadId, expected) {
7
+ if (process.platform !== 'linux')
8
+ throw new Error('Force takeover requires Linux/WSL with pidfd support');
9
+ const args = [helper, home, threadId];
10
+ if (expected)
11
+ args.push(JSON.stringify(expected));
12
+ const { stdout } = await execFileAsync('python3', args, { timeout: 12_000, maxBuffer: 16_384 });
13
+ const response = JSON.parse(stdout);
14
+ if (!response.ok)
15
+ throw new Error(response.error ?? 'External writer inspection failed');
16
+ return response.result;
17
+ }
18
+ export const externalWriterControl = {
19
+ async inspect(home, threadId) {
20
+ return await runHelper(home, threadId);
21
+ },
22
+ async stop(home, threadId, expected) {
23
+ const result = await runHelper(home, threadId, expected);
24
+ if (result.stopped !== true)
25
+ throw new Error('CLI stop was not confirmed');
26
+ },
27
+ };
@@ -66,6 +66,9 @@ export declare class BridgeSessionCore {
66
66
  private pendingUserInputs;
67
67
  private pendingMcpElicitations;
68
68
  private pendingLoginsByScope;
69
+ private externalWriterControl;
70
+ private forceTakeoversInProgress;
71
+ private pendingForceTakeovers;
69
72
  private pendingLoginScopesById;
70
73
  private pendingAuthAddsByLoginId;
71
74
  private latestTurnDiffs;
@@ -287,6 +290,8 @@ export declare class BridgeSessionCore {
287
290
  private applyObservedSessionEvents;
288
291
  private applyObservedTurnSnapshot;
289
292
  private handleTakeoverCommand;
293
+ private prepareForceTakeover;
294
+ private handleForceTakeoverCallback;
290
295
  private handleQueueCommand;
291
296
  private handleActiveTurnInboundMessage;
292
297
  private queueObservedThreadMessage;
@@ -341,6 +346,7 @@ export declare class BridgeSessionCore {
341
346
  private handleVoiceCallback;
342
347
  private sendVoiceForText;
343
348
  private handleLoginDeviceCommand;
349
+ private loginCancelKeyboard;
344
350
  private handleLoginCancelCommand;
345
351
  private handleLogoutCommand;
346
352
  private handleSteerCommand;
@@ -377,6 +383,7 @@ export declare class BridgeSessionCore {
377
383
  private requireReadyBinding;
378
384
  private handleAuthPanelActionCallback;
379
385
  private handleAuthListViewCallback;
386
+ private refreshRepairedAuthChoice;
380
387
  private handleAuthRepairMenuCallback;
381
388
  private handleAuthRepairActionCallback;
382
389
  private handleAuthToggleCallback;
@@ -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
  }
@@ -582,6 +602,20 @@ export class BridgeSessionCore {
582
602
  await this.handleLoginCancelCommand(scopeId, locale, args);
583
603
  return;
584
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
+ }
585
619
  case 'logout': {
586
620
  await this.handleLogoutCommand(scopeId, locale, args);
587
621
  return;
@@ -1072,6 +1106,21 @@ export class BridgeSessionCore {
1072
1106
  async handleCallback(event) {
1073
1107
  const scopeId = event.scopeId;
1074
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
+ }
1075
1124
  const interruptMatch = /^turn:interrupt:(.+)$/.exec(event.data);
1076
1125
  if (interruptMatch) {
1077
1126
  await this.handleTurnInterruptCallback(event, interruptMatch[1], locale);
@@ -3654,7 +3703,9 @@ export class BridgeSessionCore {
3654
3703
  if (!scopeId)
3655
3704
  return;
3656
3705
  try {
3657
- 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);
3658
3709
  }
3659
3710
  catch (notifyError) {
3660
3711
  this.logger.error('telegram.error_notification_failed', { error: toErrorMeta(notifyError), scopeId });
@@ -3887,7 +3938,7 @@ export class BridgeSessionCore {
3887
3938
  }
3888
3939
  }
3889
3940
  for (const active of [...this.activeTurns.values()]) {
3890
- 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)) {
3891
3942
  continue;
3892
3943
  }
3893
3944
  try {
@@ -3900,6 +3951,10 @@ export class BridgeSessionCore {
3900
3951
  turnId: active.turnId,
3901
3952
  error: toErrorMeta(error),
3902
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) }));
3903
3958
  }
3904
3959
  }
3905
3960
  await this.recoverQueuedTurns();
@@ -4246,6 +4301,10 @@ export class BridgeSessionCore {
4246
4301
  }
4247
4302
  }
4248
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
+ }
4249
4308
  const scopeId = event.scopeId;
4250
4309
  const nextPrompt = args.join(' ').trim();
4251
4310
  if (!nextPrompt) {
@@ -4263,10 +4322,113 @@ export class BridgeSessionCore {
4263
4322
  await this.requestInterrupt(active);
4264
4323
  }
4265
4324
  await this.sendMessage(scopeId, t(locale, 'interrupt_requested_waiting'));
4266
- 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
+ }
4267
4339
  }
4268
4340
  await this.startBoundTurnFromEvent(event, locale, nextPrompt);
4269
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
+ }
4270
4432
  async handleQueueCommand(event, locale, args) {
4271
4433
  const scopeId = event.scopeId;
4272
4434
  const nextPrompt = args.join(' ').trim();
@@ -5376,7 +5538,7 @@ export class BridgeSessionCore {
5376
5538
  t(locale, 'login_code', { value: login.userCode }),
5377
5539
  t(locale, 'login_id', { value: login.loginId }),
5378
5540
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5379
- ].join('\n'));
5541
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5380
5542
  }
5381
5543
  catch (error) {
5382
5544
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5419,7 +5581,7 @@ export class BridgeSessionCore {
5419
5581
  t(locale, 'login_code', { value: login.userCode }),
5420
5582
  t(locale, 'login_id', { value: login.loginId }),
5421
5583
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5422
- ].join('\n'));
5584
+ ].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
5423
5585
  }
5424
5586
  catch (error) {
5425
5587
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
@@ -5584,6 +5746,11 @@ export class BridgeSessionCore {
5584
5746
  }
5585
5747
  }
5586
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
+ }
5587
5754
  const login = await this.app.startDeviceLogin();
5588
5755
  const oldLoginId = this.pendingLoginsByScope.get(scopeId);
5589
5756
  if (oldLoginId) {
@@ -5598,24 +5765,41 @@ export class BridgeSessionCore {
5598
5765
  t(locale, 'login_code', { value: login.userCode }),
5599
5766
  t(locale, 'login_id', { value: login.loginId }),
5600
5767
  t(locale, 'login_cancel_hint', { value: login.loginId }),
5601
- ].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}` }]];
5602
5772
  }
5603
5773
  async handleLoginCancelCommand(scopeId, locale, args) {
5604
5774
  const loginId = args[0]?.trim() || this.pendingLoginsByScope.get(scopeId) || null;
5605
- if (!loginId) {
5775
+ if (!loginId || this.pendingLoginsByScope.get(scopeId) !== loginId) {
5606
5776
  await this.sendMessage(scopeId, t(locale, 'login_cancel_no_pending'));
5607
5777
  return;
5608
5778
  }
5609
5779
  const pendingAuthAdd = this.pendingAuthAddsByLoginId.get(loginId) ?? null;
5610
- await this.app.cancelLogin(loginId);
5780
+ // Claim cancellation before awaiting RPC: completion notifications may arrive first.
5611
5781
  this.pendingLoginsByScope.delete(scopeId);
5612
5782
  this.pendingLoginScopesById.delete(loginId);
5613
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
+ }
5614
5792
  if (pendingAuthAdd) {
5615
5793
  await this.restorePendingAuthAdd(pendingAuthAdd);
5616
5794
  await this.sendMessage(scopeId, t(locale, 'auth_add_cancelled'));
5617
5795
  return;
5618
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
+ }
5619
5803
  await this.sendMessage(scopeId, t(locale, 'login_cancelled'));
5620
5804
  }
5621
5805
  async handleLogoutCommand(scopeId, locale, args) {
@@ -6162,6 +6346,20 @@ export class BridgeSessionCore {
6162
6346
  await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
6163
6347
  }
6164
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
+ }
6165
6363
  async handleAuthRepairMenuCallback(event, localId, index, locale) {
6166
6364
  const record = this.pendingAuthChoiceLists.get(localId);
6167
6365
  if (!record) {
@@ -6177,6 +6375,8 @@ export class BridgeSessionCore {
6177
6375
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6178
6376
  return;
6179
6377
  }
6378
+ if (await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6379
+ return;
6180
6380
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
6181
6381
  if (record.messageId !== null) {
6182
6382
  await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
@@ -6197,6 +6397,8 @@ export class BridgeSessionCore {
6197
6397
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
6198
6398
  return;
6199
6399
  }
6400
+ if (action !== 'cancel' && await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
6401
+ return;
6200
6402
  if (action === 'cancel') {
6201
6403
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
6202
6404
  const state = await this.listCodexAuthState();